Merge pull request #3078 from ceruleandeep/chore/lintAndDocumentLogprobs Make dynamic reroll available without use of modifier key

61469ec999d4f16755887008b501b36a187bbca6

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +140 -114Showing whitespace changes
public/css/logprobs.css+8 -0
@@ -72,6 +72,14 @@
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+
7583.logprobs_candidate_list {
7684 grid-row-start: 3;
7785 grid-row-end: 4;
public/index.html+4 -1
@@ -6631,8 +6631,11 @@
66316631 </div>
66326632 </div>
66336633 <div class="logprobs_panel_content inline-drawer-content flex-container flexFlowColumn">
6634- <small>
6634+ <small class="flex-container alignItemsCenter justifySpaceBetween">
66356635 <b data-i18n="Select a token to see alternatives considered by the AI.">Select a token to see alternatives considered by the AI.</b>
6636+ <button id="logprobsReroll" class="menu_button" title="Reroll with the entire prefix" data-i18n="[title]Reroll with the entire prefix">
6637+ <span class="fa-solid fa-redo logprobs_reroll"></span>
6638+ </button>
66366639 </small>
66376640 <hr>
66386641 <div id="logprobs_generation_output"></div>
public/scripts/logprobs.js+128 -113
@@ -1,6 +1,5 @@
11import {
22 animation_duration,
3- callPopup,
43 chat,
54 cleanUpMessage,
65 event_types,
@@ -13,9 +12,12 @@ 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;
20+const REROLL_BUTTON = $('#logprobsReroll');
1921
2022/**
2123 * Tuple of a candidate token and its logarithm of probability of being chosen
@@ -23,6 +25,10 @@ const MAX_MESSAGE_LOGPROBS = 100;
2325 */
2426
2527/**
28+ * @typedef {(Node|JQuery<Text>|JQuery<HTMLElement>)[]} NodeArray - Array of DOM nodes
29+ */
30+
31+/**
2632 * Logprob data for a single message
2733 * @typedef {Object} MessageLogprobData
2834 * @property {number} created - timestamp of when the message was generated
@@ -43,17 +49,26 @@ const MAX_MESSAGE_LOGPROBS = 100;
4349 * @property {Candidate[]} topLogprobs - Array of top candidate tokens
4450 */
4551
46-let state = {
52+/**
47- /** @type {TokenLogprobs | null} */
53+ * State object for Token Probabilities
54+ * @typedef {Object} LogprobsState
55+ * @property {?TokenLogprobs} selectedTokenLogprobs Log probabilities for
56+ * currently-selected token.
57+ * @property {Map<number, MessageLogprobData>} messageLogprobs Log probabilities for
58+ * each message, keyed by message hash.
59+ */
60+
61+/**
62+ * @type {LogprobsState} state
63+ */
64+const state = {
4865 selectedTokenLogprobs: null,
49- /** @type {Map<number, MessageLogprobData>} */
5066 messageLogprobs: new Map(),
5167};
5268
5369/**
5470 * renderAlternativeTokensView rendersRenders the Token Probabilities UI and all subviews with the active message's
5571 * 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.
5772 */
5873function renderAlternativeTokensView() {
5974 const view = $('#logprobs_generation_output');
@@ -68,13 +83,14 @@ function renderAlternativeTokensView() {
6883 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;
6984 if (!messageLogprobs?.length || usingSmoothStreaming) {
7085 const emptyState = $('<div></div>');
7186 const noTokensMsg = usingSmoothStreaming!power_user.request_token_probabilities
72- ? 'Token probabilities are not available when using Smooth Streaming.'
87+ ? '<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.';
88+ : usingSmoothStreaming
74- const msg = power_user.request_token_probabilities
89+ ? t`Token probabilities are not available when using Smooth Streaming.`
75- ? noTokensMsg
90+ : is_send_press
76- : '<span>Enable <b>Request token probabilities</b> in the User Settings menu to use this feature.</span>';
91+ ? t`Generation in progress...`
77- emptyState.html(msg);
92+ : t`No token probabilities available for the current message.`;
93+ emptyState.html(noTokensMsg);
7894 emptyState.addClass('logprobs_empty_state');
7995 view.append(emptyState);
8096 return;
@@ -82,16 +98,34 @@ function renderAlternativeTokensView() {
8298
8399 const prefix = continueFrom || '';
84100 const tokenSpans = [];
101+ REROLL_BUTTON.toggle(!!prefix);
85102
86103 if (prefix) {
87- const prefixSpan = $('<span></span>');
104+ REROLL_BUTTON.off('click').on('click', () => onPrefixClicked(prefix.length));
88- prefixSpan.text(prefix);
105+
89- prefixSpan.html(prefixSpan.html().replace(/\n/g, '<br>'));
106+ let cumulativeOffset = 0;
90- prefixSpan.addClass('logprobs_output_prefix');
107+ const words = prefix.split(/\s+/);
91- prefixSpan.attr('title', 'Select to reroll the last \'Continue\' generation.\nHold the CTRL key when clicking to reroll from before that word.');
108+ const delimiters = prefix.match(/\s+/g) || []; // Capture the actual delimiters
92- prefixSpan.click(onPrefixClicked);
109+
93- addKeyboardProps(prefixSpan);
110+ words.forEach((word, i) => {
94- tokenSpans.push(...withVirtualWhitespace(prefix, prefixSpan));
111+ const span = $('<span></span>');
112+ span.text(`${word} `);
113+
114+ span.addClass('logprobs_output_prefix');
115+ span.attr('title', t`Reroll from this point`);
116+
117+ let offset = cumulativeOffset;
118+ span.on('click', () => onPrefixClicked(offset));
119+ addKeyboardProps(span);
120+
121+ tokenSpans.push(span);
122+ tokenSpans.push(delimiters[i]?.includes('\n')
123+ ? document.createElement('br')
124+ : document.createTextNode(delimiters[i] || ' '),
125+ );
126+
127+ cumulativeOffset += word.length + (delimiters[i]?.length || 0);
128+ });
95129 }
96130
97131 messageLogprobs.forEach((tokenData, i) => {
@@ -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 });
@@ -167,15 +204,15 @@ function renderTopLogprobs() {
167204 container.addClass('selected');
168205 }
169206
170207 const tokenText = $('<span></span>').text(`${toVisibleWhitespace(token.toString())}`);
171208 const percentText = $('<span></span>').text(`${(+probability * 100).toFixed(2)}%`);
172209 container.append(tokenText, percentText);
173210 if (log) {
174211 container.attr('title', `logarithm: ${log}`);
175212 }
176213 addKeyboardProps(container);
177214 if (token !== '<others>') {
178215 container.clickon('click', () => onAlternativeClicked(state.selectedTokenLogprobs, token.toString()));
179216 } else {
180217 container.prop('disabled', true);
181218 }
@@ -192,11 +229,10 @@ 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 {ElementNode|JQuery} span - target span node that was clicked
200236 */
201237function onSelectedTokenChanged(logprobs, span) {
202238 $('.logprobs_output_token.selected').removeClass('selected');
@@ -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 */
@@ -399,10 +403,11 @@ function toVisibleWhitespace(input) {
399403 * after the span node if its token begins or ends with whitespace in order to
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 {ElementNode|JQuery} span - target span node to be wrapped
403407 * @returns {Element[]NodeArray} - array of nodes to be appended to the DOMparent element
404408 */
405409function withVirtualWhitespace(text, span) {
410+ /** @type {NodeArray} */
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,7 +525,8 @@ function convertTokenIdLogprobsToText(input) {
512525 )));
513526
514527 // Submit token IDs to tokenizer to get token text, then build ID->text map
515- const { chunks } = decodeTextTokens(tokenizerId, tokenIds);
528+ // noinspection JSCheckFunctionSignatures - mutates input in-place
529+ const { chunks } = decodeTextTokens(tokenizerId, tokenIds.map(parseInt));
516530 const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));
517531
518532 // Fixup logprobs data with token text
@@ -525,9 +539,10 @@ function convertTokenIdLogprobsToText(input) {
525539}
526540
527541export function initLogprobs() {
542+ REROLL_BUTTON.hide();
528543 const debouncedRender = debounce(renderAlternativeTokensView);
529544 $('#logprobsViewerClose').clickon('click', onToggleLogprobsPanel);
530545 $('#option_toggle_logprobs').clickon('click', onToggleLogprobsPanel);
531546 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);
532547 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);
533548 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);