| 1 | import { branchChat } from './bookmarks.js'; |
| 2 | import { SWIPE_DIRECTION, SWIPE_SOURCE } from './constants.js'; |
| 3 | import { t } from './i18n.js'; |
| 4 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; |
| 5 | import { power_user } from './power-user.js'; |
| 6 | import { isMobile } from './RossAscends-mods.js'; |
| 7 | import { getTokenCountAsync } from './tokenizers.js'; |
| 8 | import { addLongPressEvent, clamp, copyText, timestampToMoment } from './utils.js'; |
| 9 | import { chat, deleteSwipe, ensureSwipes, isMessageSwipeable, isSwipingAllowed, swipe, syncMesToSwipe } from '/script.js'; |
| 10 | |
| 11 | /** |
| 12 | * Returns whether a swipe picker can be opened for the message. |
| 13 | * Unlike message swiping, this supports historical AI messages for inspection and branching. |
| 14 | * @param {number} messageId |
| 15 | * @returns {boolean} |
| 16 | */ |
| 17 | export function canOpenSwipePickerForMessage(messageId) { |
| 18 | const message = chat[messageId]; |
| 19 | |
| 20 | if (!message) { |
| 21 | return false; |
| 22 | } |
| 23 | |
| 24 | if (ensureSwipes(message)) { |
| 25 | syncMesToSwipe(messageId); |
| 26 | } |
| 27 | |
| 28 | return Boolean( |
| 29 | message?.swipes?.length > 1 && |
| 30 | !message?.is_user && |
| 31 | !(message?.extra?.isSmallSys) && |
| 32 | !(message?.extra?.swipeable === false), |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Returns whether the picker can actively jump to a different swipe. |
| 38 | * Historical AI messages can open the picker, but only the currently swipeable message may jump. |
| 39 | * @param {number} messageId |
| 40 | * @returns {boolean} |
| 41 | */ |
| 42 | export function canJumpToSwipeForMessage(messageId) { |
| 43 | const message = chat[messageId]; |
| 44 | return canOpenSwipePickerForMessage(messageId) && isSwipingAllowed() && isMessageSwipeable(messageId, message); |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * Opens a popup for viewing or jumping to a specific swipe on a message. |
| 49 | * @param {number} messageId |
| 50 | * @returns {Promise<void>} |
| 51 | */ |
| 52 | async function openSwipePicker(messageId) { |
| 53 | const message = chat[messageId]; |
| 54 | |
| 55 | if (!canOpenSwipePickerForMessage(messageId)) { |
| 56 | toastr.info(t`This message has no alternate swipes yet.`, t`Jump to Swipe`); |
| 57 | return; |
| 58 | } |
| 59 | |
| 60 | const canJumpToSwipe = canJumpToSwipeForMessage(messageId); |
| 61 | let selectedSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1); |
| 62 | const swipeIdInputId = `swipe_picker_id_${messageId}`; |
| 63 | const wrapper = document.createElement('div'); |
| 64 | wrapper.classList.add('flex-container', 'flexFlowColumn', 'flexNoGap', 'wide100p', 'flex1', 'overflowHidden'); |
| 65 | |
| 66 | const header = document.createElement('div'); |
| 67 | header.classList.add('swipe_picker_header', 'flex-container', 'alignItemsCenter', 'justifySpaceBetween', 'gap10px'); |
| 68 | |
| 69 | const description = document.createElement('h3'); |
| 70 | description.classList.add('margin0', 'justifyLeft'); |
| 71 | description.textContent = t`Swipe Selection`; |
| 72 | header.appendChild(description); |
| 73 | wrapper.appendChild(header); |
| 74 | |
| 75 | const listContainer = document.createElement('div'); |
| 76 | listContainer.classList.add('swipe_picker_div', 'flex1', 'marginTop10'); |
| 77 | wrapper.appendChild(listContainer); |
| 78 | |
| 79 | /** @type {Popup} */ |
| 80 | let popup; |
| 81 | /** @type {HTMLInputElement} */ |
| 82 | let swipeIdInput; |
| 83 | /** @type {number|null} */ |
| 84 | let branchActionSwipeId = null; |
| 85 | |
| 86 | function syncSwipeIdInput() { |
| 87 | if (swipeIdInput) { |
| 88 | swipeIdInput.value = String(selectedSwipeId + 1); |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | function setSelectedSwipe(nextSwipeId) { |
| 93 | selectedSwipeId = clamp(Number(nextSwipeId), 0, message.swipes.length - 1); |
| 94 | listContainer.querySelectorAll('.swipe_picker_block').forEach((element) => { |
| 95 | const isSelected = Number(element.getAttribute('data-swipe-id')) === selectedSwipeId; |
| 96 | if (isSelected) { |
| 97 | element.setAttribute('highlight', 'true'); |
| 98 | } else { |
| 99 | element.removeAttribute('highlight'); |
| 100 | } |
| 101 | }); |
| 102 | syncSwipeIdInput(); |
| 103 | } |
| 104 | |
| 105 | function scrollToSelectedSwipe() { |
| 106 | const swipeBlock = listContainer.querySelector(`.swipe_picker_block[data-swipe-id="${selectedSwipeId}"]`); |
| 107 | if (swipeBlock instanceof HTMLElement) { |
| 108 | const scrollParent = swipeBlock.closest('.swipe_picker_div'); |
| 109 | if (scrollParent instanceof HTMLElement) { |
| 110 | const blockRect = swipeBlock.getBoundingClientRect(); |
| 111 | const parentRect = scrollParent.getBoundingClientRect(); |
| 112 | if (blockRect.top < parentRect.top) { |
| 113 | scrollParent.scrollTop -= (parentRect.top - blockRect.top) + 5; |
| 114 | } else if (blockRect.bottom > parentRect.bottom) { |
| 115 | scrollParent.scrollTop += (blockRect.bottom - parentRect.bottom) + 5; |
| 116 | } |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | function canDeleteSwipeFromPicker(swipeId) { |
| 122 | if ((message?.swipes?.length ?? 0) <= 1) { |
| 123 | return false; |
| 124 | } |
| 125 | |
| 126 | const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1); |
| 127 | return canJumpToSwipe || swipeId !== currentSwipeId; |
| 128 | } |
| 129 | |
| 130 | async function renderSwipeList() { |
| 131 | const swipeBlocks = await Promise.all(message.swipes.map(async (swipe, index) => { |
| 132 | const swipeText = String(swipe ?? ''); |
| 133 | const template = $('#past_chat_template .select_chat_block_wrapper').clone(); |
| 134 | const block = template.find('.select_chat_block'); |
| 135 | block.removeClass('select_chat_block').addClass('swipe_picker_block'); |
| 136 | block.find('.select_chat_actions').removeClass('gap10px'); |
| 137 | const branchButton = template.find('.exportRawChatButton'); |
| 138 | const deleteButton = template.find('.PastChat_cross'); |
| 139 | const swipeInfo = Array.isArray(message.swipe_info) ? message.swipe_info[index] : null; |
| 140 | const sendDate = swipeInfo?.send_date ? timestampToMoment(swipeInfo.send_date).format('lll') : ''; |
| 141 | const previewText = swipeText.replace(/\s+/g, ' ').trim(); |
| 142 | const tokenCount = swipeInfo?.extra?.token_count ?? await getTokenCountAsync(swipeText, 0); |
| 143 | const canDeleteSwipe = canDeleteSwipeFromPicker(index); |
| 144 | const swipeDetails = []; |
| 145 | |
| 146 | if (previewText) { |
| 147 | swipeDetails.push(`${previewText.length} ${t`chars`}`); |
| 148 | } |
| 149 | |
| 150 | if (tokenCount) { |
| 151 | swipeDetails.push(`${tokenCount}t`); |
| 152 | } |
| 153 | |
| 154 | block.attr({ |
| 155 | file_name: `swipe-${index + 1}`, |
| 156 | 'data-swipe-id': index, |
| 157 | }); |
| 158 | |
| 159 | template.find('.renameChatButton, .exportChatButton').remove(); |
| 160 | branchButton |
| 161 | .removeAttr('data-format') |
| 162 | .attr({ |
| 163 | title: t`Create Branch`, |
| 164 | 'data-i18n': '[title]Create Branch', |
| 165 | }) |
| 166 | .removeClass('exportRawChatButton fa-solid fa-file-export') |
| 167 | .addClass('swipe_picker_branch mes_button fa-fw fa-regular fa-code-branch') |
| 168 | .on('click', async (event) => { |
| 169 | event.preventDefault(); |
| 170 | event.stopPropagation(); |
| 171 | setSelectedSwipe(index); |
| 172 | branchActionSwipeId = index; |
| 173 | await popup.completeCancelled(); |
| 174 | }); |
| 175 | deleteButton |
| 176 | .removeAttr('file_name') |
| 177 | .attr('aria-disabled', String(!canDeleteSwipe)) |
| 178 | .removeClass('fa-skull') |
| 179 | .addClass('swipe_picker_delete fa-fw fa-trash-can') |
| 180 | .toggleClass('hoverglow', canDeleteSwipe) |
| 181 | .toggleClass('disabled', !canDeleteSwipe) |
| 182 | .each(function () { |
| 183 | if (canDeleteSwipe) { |
| 184 | $(this) |
| 185 | .attr({ |
| 186 | title: t`Delete Swipe`, |
| 187 | 'data-i18n': '[title]Delete Swipe', |
| 188 | }); |
| 189 | } else { |
| 190 | $(this) |
| 191 | .removeAttr('title') |
| 192 | .removeAttr('data-i18n'); |
| 193 | } |
| 194 | }) |
| 195 | .off('click') |
| 196 | .on('click', async (event) => { |
| 197 | event.preventDefault(); |
| 198 | event.stopPropagation(); |
| 199 | |
| 200 | if (!canDeleteSwipe) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | const nextSelectedSwipeId = index < selectedSwipeId |
| 205 | ? selectedSwipeId - 1 |
| 206 | : index > selectedSwipeId |
| 207 | ? selectedSwipeId |
| 208 | : Math.min(selectedSwipeId, message.swipes.length - 2); |
| 209 | |
| 210 | if (power_user.confirm_message_delete) { |
| 211 | const result = await callGenericPopup(t`Are you sure you want to delete swipe #${index + 1}?`, POPUP_TYPE.CONFIRM, null, { |
| 212 | okButton: t`Delete Swipe`, |
| 213 | cancelButton: t`Cancel`, |
| 214 | }); |
| 215 | |
| 216 | if (result !== POPUP_RESULT.AFFIRMATIVE) { |
| 217 | return; |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | const newSwipeId = await deleteSwipe(index, messageId); |
| 222 | if (!Number.isInteger(newSwipeId)) { |
| 223 | return; |
| 224 | } |
| 225 | |
| 226 | selectedSwipeId = clamp(nextSelectedSwipeId, 0, message.swipes.length - 1); |
| 227 | |
| 228 | if (swipeIdInput instanceof HTMLInputElement) { |
| 229 | swipeIdInput.max = String(message.swipes.length); |
| 230 | } |
| 231 | |
| 232 | await renderSwipeList(); |
| 233 | }); |
| 234 | |
| 235 | // Add expand/collapse toggle |
| 236 | const expandCheckboxId = `swipe_picker_expand_${messageId}_${index}`; |
| 237 | const expandCheckbox = document.createElement('input'); |
| 238 | expandCheckbox.type = 'checkbox'; |
| 239 | expandCheckbox.id = expandCheckboxId; |
| 240 | expandCheckbox.classList.add('swipe_picker_expand_toggle'); |
| 241 | block[0].prepend(expandCheckbox); |
| 242 | |
| 243 | const expandLabel = document.createElement('label'); |
| 244 | expandLabel.htmlFor = expandCheckboxId; |
| 245 | expandLabel.classList.add('swipe_picker_expand_label', 'fa-solid', 'fa-fw', 'fa-chevron-down'); |
| 246 | expandLabel.title = t`Expand/Collapse`; |
| 247 | expandLabel.setAttribute('data-i18n', '[title]Expand/Collapse'); |
| 248 | expandLabel.addEventListener('click', (event) => event.stopPropagation()); |
| 249 | |
| 250 | // Add copy button |
| 251 | const copyButton = document.createElement('div'); |
| 252 | copyButton.classList.add('swipe_picker_copy', 'fa-solid', 'fa-fw', 'fa-copy'); |
| 253 | copyButton.title = t`Copy`; |
| 254 | copyButton.setAttribute('data-i18n', '[title]Copy'); |
| 255 | copyButton.addEventListener('click', async (event) => { |
| 256 | event.preventDefault(); |
| 257 | event.stopPropagation(); |
| 258 | await copyText(swipeText); |
| 259 | toastr.info(t`Copied!`, '', { timeOut: 2000 }); |
| 260 | }); |
| 261 | |
| 262 | // Insert new buttons before the branch button |
| 263 | branchButton.before(expandLabel, copyButton); |
| 264 | |
| 265 | template.find('.select_chat_block_filename').text(`#${index + 1}${index === Number(message.swipe_id ?? 0) ? ` ${t`[Current]`}` : ''}`); |
| 266 | template.find('.chat_messages_date').text(sendDate); |
| 267 | template.find('.chat_file_size').text(swipeDetails.length ? `(${swipeDetails[0]}${swipeDetails.length > 1 ? ',' : ')'}` : ''); |
| 268 | template.find('.chat_messages_num').text(swipeDetails.length > 1 ? `${swipeDetails.slice(1).join(', ')})` : ''); |
| 269 | template.find('.select_chat_block_mes').text(previewText ? swipeText : t`(empty swipe)`); |
| 270 | |
| 271 | block.on('click', () => setSelectedSwipe(index)); |
| 272 | block.on('dblclick', async () => { |
| 273 | if (!canJumpToSwipe) { |
| 274 | return; |
| 275 | } |
| 276 | |
| 277 | setSelectedSwipe(index); |
| 278 | await popup.completeAffirmative(); |
| 279 | }); |
| 280 | |
| 281 | return template[0]; |
| 282 | })); |
| 283 | |
| 284 | listContainer.replaceChildren(...swipeBlocks); |
| 285 | setSelectedSwipe(selectedSwipeId); |
| 286 | |
| 287 | if (swipeBlocks.length === 0) { |
| 288 | const empty = document.createElement('div'); |
| 289 | empty.classList.add('textAlignCenter', 'opacity50p', 'padding10'); |
| 290 | empty.textContent = t`No swipes available.`; |
| 291 | listContainer.replaceChildren(empty); |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | popup = new Popup(wrapper, POPUP_TYPE.CONFIRM, '', { |
| 296 | okButton: canJumpToSwipe ? t`Go` : false, |
| 297 | cancelButton: false, |
| 298 | customInputs: [{ |
| 299 | id: swipeIdInputId, |
| 300 | label: t`Swipe ID`, |
| 301 | type: 'text', |
| 302 | defaultState: String(selectedSwipeId + 1), |
| 303 | tooltip: `1-${message.swipes.length}`, |
| 304 | }], |
| 305 | large: true, |
| 306 | wider: true, |
| 307 | allowVerticalScrolling: true, |
| 308 | onOpen: function () { |
| 309 | scrollToSelectedSwipe(); |
| 310 | if (swipeIdInput instanceof HTMLInputElement) { |
| 311 | swipeIdInput.focus(); |
| 312 | swipeIdInput.select(); |
| 313 | } |
| 314 | }, |
| 315 | onClosing: function (popup) { |
| 316 | if (popup.result !== POPUP_RESULT.AFFIRMATIVE) { |
| 317 | return true; |
| 318 | } |
| 319 | |
| 320 | const swipeIdInput = popup.dlg.querySelector(`#${swipeIdInputId}`); |
| 321 | const targetSwipeNumber = Number.parseInt(String(swipeIdInput instanceof HTMLInputElement ? swipeIdInput.value : '').trim(), 10); |
| 322 | |
| 323 | if (!Number.isInteger(targetSwipeNumber) || targetSwipeNumber < 1 || targetSwipeNumber > message.swipes.length) { |
| 324 | toastr.warning(t`Enter a swipe ID between 1 and ${message.swipes.length}.`, t`Jump to Swipe`); |
| 325 | if (swipeIdInput instanceof HTMLInputElement) { |
| 326 | swipeIdInput.focus(); |
| 327 | swipeIdInput.select(); |
| 328 | } |
| 329 | return false; |
| 330 | } |
| 331 | |
| 332 | setSelectedSwipe(targetSwipeNumber - 1); |
| 333 | return true; |
| 334 | }, |
| 335 | }); |
| 336 | |
| 337 | popup.dlg.classList.add('swipe_picker_popup'); |
| 338 | popup.closeButton.style.display = 'block'; |
| 339 | popup.closeButton.classList.add('opacity50p', 'hoverglow', 'fontsize120p'); |
| 340 | popup.closeButton.style.position = 'static'; |
| 341 | popup.closeButton.style.top = 'auto'; |
| 342 | popup.closeButton.style.right = 'auto'; |
| 343 | popup.closeButton.style.width = 'auto'; |
| 344 | popup.closeButton.style.height = 'auto'; |
| 345 | popup.closeButton.style.padding = '0'; |
| 346 | popup.closeButton.style.filter = 'none'; |
| 347 | header.appendChild(popup.closeButton); |
| 348 | |
| 349 | swipeIdInput = popup.dlg.querySelector(`#${swipeIdInputId}`); |
| 350 | const swipeIdLabel = popup.dlg.querySelector(`label[for="${swipeIdInputId}"]`); |
| 351 | |
| 352 | if (swipeIdLabel instanceof HTMLLabelElement) { |
| 353 | swipeIdLabel.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'gap10px', 'margin0'); |
| 354 | popup.buttonControls.insertBefore(swipeIdLabel, canJumpToSwipe ? popup.okButton : popup.buttonControls.firstChild); |
| 355 | popup.inputControls.style.display = 'none'; |
| 356 | } |
| 357 | |
| 358 | if (swipeIdInput instanceof HTMLInputElement) { |
| 359 | swipeIdInput.type = 'number'; |
| 360 | swipeIdInput.min = '1'; |
| 361 | swipeIdInput.max = String(message.swipes.length); |
| 362 | swipeIdInput.step = '1'; |
| 363 | swipeIdInput.inputMode = 'numeric'; |
| 364 | swipeIdInput.classList.add('flex1', 'width100px', 'textAlignCenter'); |
| 365 | swipeIdInput.setAttribute('autofocus', ''); |
| 366 | syncSwipeIdInput(); |
| 367 | |
| 368 | swipeIdInput.addEventListener('input', function () { |
| 369 | const nextSwipeId = Number.parseInt(this.value, 10); |
| 370 | if (!Number.isInteger(nextSwipeId) || nextSwipeId < 1 || nextSwipeId > message.swipes.length) { |
| 371 | return; |
| 372 | } |
| 373 | |
| 374 | setSelectedSwipe(nextSwipeId - 1); |
| 375 | scrollToSelectedSwipe(); |
| 376 | }); |
| 377 | |
| 378 | swipeIdInput.addEventListener('blur', function () { |
| 379 | syncSwipeIdInput(); |
| 380 | }); |
| 381 | } |
| 382 | |
| 383 | await renderSwipeList(); |
| 384 | |
| 385 | const popupResult = await popup.show(); |
| 386 | |
| 387 | if (branchActionSwipeId !== null) { |
| 388 | await branchChat(messageId, { swipeId: branchActionSwipeId }); |
| 389 | return; |
| 390 | } |
| 391 | |
| 392 | if (popupResult !== POPUP_RESULT.AFFIRMATIVE) { |
| 393 | return; |
| 394 | } |
| 395 | |
| 396 | if (!canJumpToSwipe) { |
| 397 | return; |
| 398 | } |
| 399 | |
| 400 | const targetSwipeId = clamp(selectedSwipeId, 0, message.swipes.length - 1); |
| 401 | const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1); |
| 402 | |
| 403 | if (targetSwipeId === currentSwipeId) { |
| 404 | toastr.info(t`Already showing swipe #${targetSwipeId + 1}.`, t`Jump to Swipe`); |
| 405 | return; |
| 406 | } |
| 407 | |
| 408 | const direction = targetSwipeId > currentSwipeId ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT; |
| 409 | await swipe(null, direction, { source: SWIPE_SOURCE.SWIPE_PICKER, forceMesId: messageId, forceSwipeId: targetSwipeId }); |
| 410 | } |
| 411 | |
| 412 | export function initSwipePicker() { |
| 413 | /** |
| 414 | * Click handler for opening the swipe picker when clicking on the swipe counter. |
| 415 | * @param {JQuery.Event | Event} e Event object |
| 416 | */ |
| 417 | async function onSwipeCounterClick(e) { |
| 418 | e.preventDefault(); |
| 419 | e.stopPropagation(); |
| 420 | |
| 421 | const mesId = Number($(this).closest('.mes').attr('mesid')); |
| 422 | await openSwipePicker(mesId); |
| 423 | } |
| 424 | |
| 425 | if (isMobile()) { |
| 426 | addLongPressEvent('.swipes-counter.swipe-picker-enabled', onSwipeCounterClick); |
| 427 | } else { |
| 428 | $(document).on('click', '.swipes-counter.swipe-picker-enabled', onSwipeCounterClick); |
| 429 | } |
| 430 | $(document).on('keydown', '.swipes-counter.swipe-picker-enabled', async function (e) { |
| 431 | if (e.key !== ' ') { |
| 432 | return; |
| 433 | } |
| 434 | |
| 435 | onSwipeCounterClick.call(this, e); |
| 436 | }); |
| 437 | $(document).on('click', '.mes_swipe_picker', async function (e) { |
| 438 | e.preventDefault(); |
| 439 | e.stopPropagation(); |
| 440 | |
| 441 | const mesId = Number($(this).closest('.mes').attr('mesid')); |
| 442 | await openSwipePicker(mesId); |
| 443 | }); |
| 444 | } |