Blame Raw
Cohee · 51ad27fb · · 444 lines (17.5 KB)
3 contributors
1import { branchChat } from './bookmarks.js';
2import { SWIPE_DIRECTION, SWIPE_SOURCE } from './constants.js';
3import { t } from './i18n.js';
4import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
5import { power_user } from './power-user.js';
6import { isMobile } from './RossAscends-mods.js';
7import { getTokenCountAsync } from './tokenizers.js';
8import { addLongPressEvent, clamp, copyText, timestampToMoment } from './utils.js';
9import { 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 */
17export 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 */
42export 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 */
52async 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
412export 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}