Make dynamic reroll available without use of modifier key Linting

5e883e446a20a5a8b074a28eaee111ba6deb3ee5

ceruleandeep <deep@cerulean.navy>

2 files changed, +136 -108Showing whitespace changes
public/css/logprobs.css+14 -0
@@ -72,6 +72,20 @@
7272 opacity: 0.5;
7373}
7474
75+.logprobs_output_prefix:hover {
76+ background-color: rgba(255, 0, 50, 0.4);
77+}
78+
79+.logprobs_output_prefix:hover ~ .logprobs_output_prefix {
80+ background-color: rgba(255, 0, 50, 0.4);
81+}
82+
83+#logprobsReroll {
84+ float: right; /* Position the button to the right */
85+ margin: 5px 0 5px 10px; /* Add spacing (top, right, bottom, left) */
86+ clear: right; /* Ensure it starts on a new line */
87+}
88+
7589.logprobs_candidate_list {
7690 grid-row-start: 3;
7791 grid-row-end: 4;
public/scripts/logprobs.js+122 -108
@@ -1,6 +1,5 @@
11import {
22 animation_duration,
3- callPopup,
43 chat,
54 cleanUpMessage,
65 event_types,
@@ -13,6 +12,8 @@ import {
1312import { debounce, delay, getStringHash } from './utils.js';
1413import { decodeTextTokens, getTokenizerBestMatch } from './tokenizers.js';
1514import { power_user } from './power-user.js';
15+import { callGenericPopup, POPUP_TYPE } from './popup.js';
16+import { t } from './i18n.js';
1617
1718const TINTS = 4;
1819const MAX_MESSAGE_LOGPROBS = 100;
@@ -43,17 +44,26 @@ const MAX_MESSAGE_LOGPROBS = 100;
4344 * @property {Candidate[]} topLogprobs - Array of top candidate tokens
4445 */
4546
46-let state = {
47+/**
47- /** @type {TokenLogprobs | null} */
48+ * State object for Token Probabilities
49+ * @typedef {Object} LogprobsState
50+ * @property {?TokenLogprobs} selectedTokenLogprobs Log probabilities for
51+ * currently-selected token.
52+ * @property {Map<number, MessageLogprobData>} messageLogprobs Log probabilities for
53+ * each message, keyed by message hash.
54+ */
55+
56+/**
57+ * @type {LogprobsState} state
58+ */
59+const state = {
4860 selectedTokenLogprobs: null,
49- /** @type {Map<number, MessageLogprobData>} */
5061 messageLogprobs: new Map(),
5162};
5263
5364/**
5465 * renderAlternativeTokensView rendersRenders the Token Probabilities UI and all subviews with the active message's
5566 * subviews with the active message's logprobs data. If the message has no token logprobs, a message is displayed.
56- * logprobs, a zero-state is rendered.
5767 */
5868function renderAlternativeTokensView() {
5969 const view = $('#logprobs_generation_output');
@@ -68,13 +78,14 @@ function renderAlternativeTokensView() {
6878 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;
6979 if (!messageLogprobs?.length || usingSmoothStreaming) {
7080 const emptyState = $('<div></div>');
7181 const noTokensMsg = usingSmoothStreaming!power_user.request_token_probabilities
72- ? 'Token probabilities are not available when using Smooth Streaming.'
82+ ? '<span>Enable <b>Request token probabilities</b> in the User Settings menu to use this feature.</span>'
73- : 'No token probabilities available for the current message.';
83+ : usingSmoothStreaming
74- const msg = power_user.request_token_probabilities
84+ ? t`Token probabilities are not available when using Smooth Streaming.`
75- ? noTokensMsg
85+ : is_send_press
76- : '<span>Enable <b>Request token probabilities</b> in the User Settings menu to use this feature.</span>';
86+ ? t`Generation in progress...`
77- emptyState.html(msg);
87+ : t`No token probabilities available for the current message.`;
88+ emptyState.html(noTokensMsg);
7889 emptyState.addClass('logprobs_empty_state');
7990 view.append(emptyState);
8091 return;
@@ -84,16 +95,39 @@ function renderAlternativeTokensView() {
8495 const tokenSpans = [];
8596
8697 if (prefix) {
8798 const prefixSpanrerollButton = $('<span></spanbutton id="logprobsReroll" class="menu_button">'); +
88- prefixSpan.text(prefix);
99+ ' <span class="fa-solid fa-redo logprobs_reroll"></span>' +
89- prefixSpan.html(prefixSpan.html().replace(/\n/g, '<br>'));
100+ '</button>');
90- prefixSpan.addClass('logprobs_output_prefix');
101+ rerollButton.attr('title', t`Reroll with the entire prefix`);
91- prefixSpan.attr('title', 'Select to reroll the last \'Continue\' generation.\nHold the CTRL key when clicking to reroll from before that word.');
102+ rerollButton.on('click', () => onPrefixClicked(prefix.length));
92103 prefixSpantokenSpans.clickpush(onPrefixClickedrerollButton);
93- addKeyboardProps(prefixSpan);
104+
94- tokenSpans.push(...withVirtualWhitespace(prefix, prefixSpan));
105+ let cumulativeOffset = 0;
106+ const words = prefix.split(/\s+/);
107+ const delimiters = prefix.match(/\s+/g) || []; // Capture the actual delimiters
108+
109+ words.forEach((word, i) => {
110+ const span = $('<span></span>');
111+ span.text(`${word} `);
112+
113+ span.addClass('logprobs_output_prefix');
114+ span.attr('title', t`Reroll from this point`);
115+
116+ let offset = cumulativeOffset;
117+ span.on('click', () => onPrefixClicked(offset));
118+ addKeyboardProps(span);
119+
120+ tokenSpans.push(span);
121+ tokenSpans.push(delimiters[i]?.includes('\n')
122+ ? document.createElement('br')
123+ : document.createTextNode(delimiters[i] || ' '),
124+ );
125+
126+ cumulativeOffset += word.length + (delimiters[i]?.length || 0);
127+ });
95128 }
96129
130+
97131 messageLogprobs.forEach((tokenData, i) => {
98132 const { token } = tokenData;
99133 const span = $('<span></span>');
@@ -101,7 +135,7 @@ function renderAlternativeTokensView() {
101135 span.text(text);
102136 span.addClass('logprobs_output_token');
103137 span.addClass('logprobs_tint_' + (i % TINTS));
104138 span.clickon('click', () => onSelectedTokenChanged(tokenData, span));
105139 addKeyboardProps(span);
106140 tokenSpans.push(...withVirtualWhitespace(token, span));
107141 });
@@ -129,6 +163,10 @@ function addKeyboardProps(element) {
129163/**
130164 * renderTopLogprobs renders the top logprobs subview with the currently
131165 * selected token highlighted. If no token is selected, the subview is hidden.
166+ *
167+ * Callers:
168+ * - renderAlternativeTokensView, to render the entire view
169+ * - onSelectedTokenChanged, to update the view when a token is selected
132170 */
133171function renderTopLogprobs() {
134172 $('#logprobs_top_logprobs_hint').hide();
@@ -150,8 +188,7 @@ function renderTopLogprobs() {
150188 const probability = Math.exp(log);
151189 sum += probability;
152190 return [text, probability, log];
153191 } else {
154- else {
155192 return [text, log, null];
156193 }
157194 });
@@ -175,7 +212,7 @@ function renderTopLogprobs() {
175212 }
176213 addKeyboardProps(container);
177214 if (token !== '<others>') {
178215 container.clickon('click', () => onAlternativeClicked(state.selectedTokenLogprobs, token));
179216 } else {
180217 container.prop('disabled', true);
181218 }
@@ -192,9 +229,8 @@ function renderTopLogprobs() {
192229}
193230
194231/**
195- * onSelectedTokenChanged is called when the user clicks on a token in the
232+ * User clicks on a token in the token output view. It updates the selected token state
196- * token output view. It updates the selected token state and re-renders the
233+ * and re-renders the top logprobs view, or deselects the token if it was already selected.
197- * top logprobs view, or deselects the token if it was already selected.
198234 * @param {TokenLogprobs} logprobs - logprob data for the selected token
199235 * @param {Element} span - target span node that was clicked
200236 */
@@ -223,7 +259,10 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
223259 }
224260
225261 if (getGeneratingApi() === 'openai') {
226- return callPopup('<h3>Feature unavailable</h3><p>Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.</p>', 'text');
262+ const title = t`Feature unavailable`;
263+ const message = t`Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.`;
264+ const content = `<h3>${title}</h3><p>${message}</p>`;
265+ return callGenericPopup(content, POPUP_TYPE.TEXT);
227266 }
228267
229268 const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();
@@ -234,79 +273,29 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
234273
235274 const prefix = continueFrom || '';
236275 const prompt = prefix + tokens.join('');
237- const messageId = chat.length - 1;
276+ addGeneration(prompt);
238- createSwipe(messageId, prompt);
239-
240- $('.swipe_right:last').click(); // :see_no_evil:
241-
242- Generate('continue').then(_ => void _);
243277}
244278
245279/**
246- * getTextBeforeClickedWord retrieves the portion of text within a span
280+ * User clicks on the reroll button in the token output view, or on a word in the
247281 * that appearsprefix. beforeRetrieve the word clickedprefix byfor the user.current Usingmessage theand xtruncate andit yat the
248- * coordinates from a PointerEvent, this function identifies the exact
282+ * offset for the selected word. Then request a `continue` completion from the
249- * word clicked and returns the text preceding it within the span.
283+ * model with the new prompt.
250- *
251- * If the clicked position does not resolve to a valid word or text node,
252- * the entire span text is returned as a fallback.
253284 *
254- * @param {PointerEvent} event - The click event containing the x and y coordinates.
285+ * If no offset is provided, the entire prefix will be rerolled.
255- * @param {string} spanText - The full text content of the span element.
256- * @returns {string} The text before the clicked word, or the entire span text as fallback.
257- */
258-function getTextBeforeClickedWord(event, spanText) {
259- const x = event.clientX;
260- const y = event.clientY;
261- const range = document.caretRangeFromPoint(x, y);
262-
263- if (range && range.startContainer.nodeType === Node.TEXT_NODE) {
264- const textNode = range.startContainer;
265- const offset = range.startOffset;
266-
267- // Get the full text content of the text node
268- const text = textNode.nodeValue;
269-
270- // Find the boundaries of the clicked word
271- const start = text.lastIndexOf(' ', offset - 1) + 1;
272-
273- // Return the text before the clicked word
274- return text.slice(0, start);
275- }
276-
277- // If we can't determine the exact word, return the full span text as a fallback
278- return spanText;
279-}
280-
281-
282-/**
283- * onPrefixClicked is called when the user clicks on the carried-over prefix
284- * in the token output view. It allows them to reroll the last 'continue'
285- * completion with none of the output generated from it, in case they don't
286- * like the results.
287286 *
288287 * If the@param user{number} holdsoffset the- Ctrlindex keyof whilethe clicking,token onlyin the portionprefix ofto textreroll from
289- * before the clicked word is retained as the prefix for rerolling
288+ * @returns {void}
289+ * @param offset
290290 */
291291function onPrefixClicked(offset = undefined) {
292292 if (!checkGenerateReady()) {
293293 return;
294294 }
295295
296296 const { continueFrom } = getActiveMessageLogprobData() || {};
297- const messageId = chat.length - 1;
297+ const prefix = continueFrom ? continueFrom.substring(0, offset) : '';
298-
298+ addGeneration(prefix);
299- // Check if Ctrl key is pressed during the click
300- let prefix = continueFrom || '';
301- if (event.ctrlKey) {
302- // Ctrl is pressed - use the text before the clicked word
303- prefix = getTextBeforeClickedWord(event, continueFrom);
304- }
305-
306- // Use the determined `prefix`
307- createSwipe(messageId, prefix);
308- $('.swipe_right:last').click();
309- Generate('continue').then(_ => void _);
310299}
311300
312301function checkGenerateReady() {
@@ -317,6 +306,22 @@ function checkGenerateReady() {
317306 return true;
318307}
319308
309+/**
310+ * Generates a new swipe as a continuation of the given prompt, when user selects
311+ * an alternative token or rerolls from a prefix.
312+ *
313+ * @param prompt
314+ */
315+function addGeneration(prompt) {
316+ const messageId = chat.length - 1;
317+ if (prompt && prompt.length > 0) {
318+ createSwipe(messageId, prompt);
319+ $('.swipe_right:last').trigger('click');
320+ void Generate('continue');
321+ } else {
322+ $('.swipe_right:last').trigger('click');
323+ }
324+}
320325
321326/**
322327 * onToggleLogprobsPanel is called when the user performs an action that toggles
@@ -356,8 +361,7 @@ function onToggleLogprobsPanel() {
356361}
357362
358363/**
359364 * createSwipe appendsAppends a new swipe to the target chat message with the given text.
360- * text.
361365 * @param {number} messageId - target chat message ID
362366 * @param {string} prompt - initial prompt text which will be continued
363367 */
@@ -400,9 +404,10 @@ function toVisibleWhitespace(input) {
400404 * allow text to wrap despite whitespace characters being replaced with a dot.
401405 * @param {string} text - token text being evaluated for whitespace
402406 * @param {Element} span - target span node to be wrapped
403407 * @returns {ElementNode[]} - array of nodes to be appended to the DOMparent element
404408 */
405409function withVirtualWhitespace(text, span) {
410+ /** @type {Node[]} */
406411 const result = [span];
407412 if (text.match(/^\s/)) {
408413 result.unshift(document.createTextNode('\u200b'));
@@ -430,12 +435,16 @@ function withVirtualWhitespace(text, span) {
430435}
431436
432437/**
433- * saveLogprobsForActiveMessage receives an array of TokenLogprobs objects
438+ * Receives the top logprobs for each token in a message and associates it with the active message.
434- * representing the top logprobs for each token in a message and associates it
439+ *
435- * with the active message.
440+ * Ensure the active message has been updated and rendered before calling this function
441+ * or the logprobs data will be saved to the wrong message.
442+ *
443+ * Callers:
444+ * - Generate:onSuccess via saveLogprobsForActiveMessage, for non-streaming text completion
445+ * - StreamingProcessor:onFinishStreaming, for streaming text completion
446+ * - sendOpenAIRequest, for non-streaming chat completion
436447 *
437- * **Ensure the active message has been updated and rendered before calling
438- * this function or the logprobs data will be saved to the wrong message.**
439448 * @param {TokenLogprobs[]} logprobs - array of logprobs data for each token
440449 * @param {string | null} continueFrom - for 'continue' generations, the prompt
441450 */
@@ -445,7 +454,10 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) {
445454 return;
446455 }
447456
457+ // NovelAI only returns token IDs in logprobs data; convert to text tokens in-place
458+ if (getGeneratingApi() === 'novel') {
448459 convertTokenIdLogprobsToText(logprobs);
460+ }
449461
450462 const msgId = chat.length - 1;
451463 /** @type {MessageLogprobData} */
@@ -491,17 +503,18 @@ function getActiveMessageLogprobData() {
491503 return state.messageLogprobs.get(hash) || null;
492504}
493505
506+
494507/**
495508 * convertLogprobTokenIdsToText mutatesreplaces thetoken givenIDs in logprobs data's topLogprobswith text tokens,
496509 * fieldfor keyedAPIs bythat return token textIDs instead of token ID. Thistext istokens, onlyto necessarywit: forNovelAI.
497- * APIs which only return token IDs in their logprobs data; for others this
510+ *
498- * function is a no-op.
499511 * @param {TokenLogprobs[]} input - logprobs data with numeric token IDs
500512 */
501513function convertTokenIdLogprobsToText(input) {
502514 const api = getGeneratingApi();
503515 if (api !== 'novel') {
504- return input;
516+ // should have been checked by the caller
517+ throw new Error('convertTokenIdLogprobsToText should only be called for NovelAI');
505518 }
506519
507520 const tokenizerId = getTokenizerBestMatch(api);
@@ -512,6 +525,7 @@ function convertTokenIdLogprobsToText(input) {
512525 )));
513526
514527 // Submit token IDs to tokenizer to get token text, then build ID->text map
528+ // noinspection JSCheckFunctionSignatures - mutates input in-place
515529 const { chunks } = decodeTextTokens(tokenizerId, tokenIds);
516530 const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));
517531
@@ -526,8 +540,8 @@ function convertTokenIdLogprobsToText(input) {
526540
527541export function initLogprobs() {
528542 const debouncedRender = debounce(renderAlternativeTokensView);
529543 $('#logprobsViewerClose').clickon('click', onToggleLogprobsPanel);
530544 $('#option_toggle_logprobs').clickon('click', onToggleLogprobsPanel);
531545 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);
532546 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);
533547 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);