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, +141 -115Ignore whitespace
public/css/logprobs.css+8 -0
@@ -72,6 +72,14 @@
72 opacity: 0.5;72 opacity: 0.5;
73}73}
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
75.logprobs_candidate_list {83.logprobs_candidate_list {
76 grid-row-start: 3;84 grid-row-start: 3;
77 grid-row-end: 4;85 grid-row-end: 4;
public/index.html+4 -1
@@ -6631,8 +6631,11 @@
6631 </div>6631 </div>
6632 </div>6632 </div>
6633 <div class="logprobs_panel_content inline-drawer-content flex-container flexFlowColumn">6633 <div class="logprobs_panel_content inline-drawer-content flex-container flexFlowColumn">
6634 <small>6634 <small class="flex-container alignItemsCenter justifySpaceBetween">
6635 <b data-i18n="Select a token to see alternatives considered by the AI.">Select a token to see alternatives considered by the AI.</b>6635 <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>
6636 </small>6639 </small>
6637 <hr>6640 <hr>
6638 <div id="logprobs_generation_output"></div>6641 <div id="logprobs_generation_output"></div>
public/scripts/logprobs.js+129 -114
@@ -1,6 +1,5 @@
1import {1import {
2 animation_duration,2 animation_duration,
3 callPopup,
4 chat,3 chat,
5 cleanUpMessage,4 cleanUpMessage,
6 event_types,5 event_types,
@@ -13,9 +12,12 @@ import {
13import { debounce, delay, getStringHash } from './utils.js';12import { debounce, delay, getStringHash } from './utils.js';
14import { decodeTextTokens, getTokenizerBestMatch } from './tokenizers.js';13import { decodeTextTokens, getTokenizerBestMatch } from './tokenizers.js';
15import { power_user } from './power-user.js';14import { power_user } from './power-user.js';
15import { callGenericPopup, POPUP_TYPE } from './popup.js';
16import { t } from './i18n.js';
1617
17const TINTS = 4;18const TINTS = 4;
18const MAX_MESSAGE_LOGPROBS = 100;19const MAX_MESSAGE_LOGPROBS = 100;
20const REROLL_BUTTON = $('#logprobsReroll');
1921
20/**22/**
21 * Tuple of a candidate token and its logarithm of probability of being chosen23 * Tuple of a candidate token and its logarithm of probability of being chosen
@@ -23,6 +25,10 @@ const MAX_MESSAGE_LOGPROBS = 100;
23 */25 */
2426
25/**27/**
28 * @typedef {(Node|JQuery<Text>|JQuery<HTMLElement>)[]} NodeArray - Array of DOM nodes
29 */
30
31/**
26 * Logprob data for a single message32 * Logprob data for a single message
27 * @typedef {Object} MessageLogprobData33 * @typedef {Object} MessageLogprobData
28 * @property {number} created - timestamp of when the message was generated34 * @property {number} created - timestamp of when the message was generated
@@ -43,17 +49,26 @@ const MAX_MESSAGE_LOGPROBS = 100;
43 * @property {Candidate[]} topLogprobs - Array of top candidate tokens49 * @property {Candidate[]} topLogprobs - Array of top candidate tokens
44 */50 */
4551
46let 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 */
64const state = {
48 selectedTokenLogprobs: null,65 selectedTokenLogprobs: null,
49 /** @type {Map<number, MessageLogprobData>} */
50 messageLogprobs: new Map(),66 messageLogprobs: new Map(),
51};67};
5268
53/**69/**
54 * renderAlternativeTokensView renders the Token Probabilities UI and all70 * Renders the Token Probabilities UI and all subviews with the active message's
55 * subviews with the active message's logprobs data. If the message has no token71 * logprobs data. If the message has no token logprobs, a message is displayed.
56 * logprobs, a zero-state is rendered.
57 */72 */
58function renderAlternativeTokensView() {73function renderAlternativeTokensView() {
59 const view = $('#logprobs_generation_output');74 const view = $('#logprobs_generation_output');
@@ -68,13 +83,14 @@ function renderAlternativeTokensView() {
68 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;83 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;
69 if (!messageLogprobs?.length || usingSmoothStreaming) {84 if (!messageLogprobs?.length || usingSmoothStreaming) {
70 const emptyState = $('<div></div>');85 const emptyState = $('<div></div>');
71 const noTokensMsg = usingSmoothStreaming86 const noTokensMsg = !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_probabilities89 ? t`Token probabilities are not available when using Smooth Streaming.`
75 ? noTokensMsg90 : 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);
78 emptyState.addClass('logprobs_empty_state');94 emptyState.addClass('logprobs_empty_state');
79 view.append(emptyState);95 view.append(emptyState);
80 return;96 return;
@@ -82,16 +98,34 @@ function renderAlternativeTokensView() {
8298
83 const prefix = continueFrom || '';99 const prefix = continueFrom || '';
84 const tokenSpans = [];100 const tokenSpans = [];
101 REROLL_BUTTON.toggle(!!prefix);
85102
86 if (prefix) {103 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 });
95 }129 }
96130
97 messageLogprobs.forEach((tokenData, i) => {131 messageLogprobs.forEach((tokenData, i) => {
@@ -101,7 +135,7 @@ function renderAlternativeTokensView() {
101 span.text(text);135 span.text(text);
102 span.addClass('logprobs_output_token');136 span.addClass('logprobs_output_token');
103 span.addClass('logprobs_tint_' + (i % TINTS));137 span.addClass('logprobs_tint_' + (i % TINTS));
104 span.click(() => onSelectedTokenChanged(tokenData, span));138 span.on('click', () => onSelectedTokenChanged(tokenData, span));
105 addKeyboardProps(span);139 addKeyboardProps(span);
106 tokenSpans.push(...withVirtualWhitespace(token, span));140 tokenSpans.push(...withVirtualWhitespace(token, span));
107 });141 });
@@ -129,6 +163,10 @@ function addKeyboardProps(element) {
129/**163/**
130 * renderTopLogprobs renders the top logprobs subview with the currently164 * renderTopLogprobs renders the top logprobs subview with the currently
131 * selected token highlighted. If no token is selected, the subview is hidden.165 * 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
132 */170 */
133function renderTopLogprobs() {171function renderTopLogprobs() {
134 $('#logprobs_top_logprobs_hint').hide();172 $('#logprobs_top_logprobs_hint').hide();
@@ -150,8 +188,7 @@ function renderTopLogprobs() {
150 const probability = Math.exp(log);188 const probability = Math.exp(log);
151 sum += probability;189 sum += probability;
152 return [text, probability, log];190 return [text, probability, log];
153 }191 } else {
154 else {
155 return [text, log, null];192 return [text, log, null];
156 }193 }
157 });194 });
@@ -167,15 +204,15 @@ function renderTopLogprobs() {
167 container.addClass('selected');204 container.addClass('selected');
168 }205 }
169206
170 const tokenText = $('<span></span>').text(`${toVisibleWhitespace(token)}`);207 const tokenText = $('<span></span>').text(`${toVisibleWhitespace(token.toString())}`);
171 const percentText = $('<span></span>').text(`${(probability * 100).toFixed(2)}%`);208 const percentText = $('<span></span>').text(`${(+probability * 100).toFixed(2)}%`);
172 container.append(tokenText, percentText);209 container.append(tokenText, percentText);
173 if (log) {210 if (log) {
174 container.attr('title', `logarithm: ${log}`);211 container.attr('title', `logarithm: ${log}`);
175 }212 }
176 addKeyboardProps(container);213 addKeyboardProps(container);
177 if (token !== '<others>') {214 if (token !== '<others>') {
178 container.click(() => onAlternativeClicked(state.selectedTokenLogprobs, token));215 container.on('click', () => onAlternativeClicked(state.selectedTokenLogprobs, token.toString()));
179 } else {216 } else {
180 container.prop('disabled', true);217 container.prop('disabled', true);
181 }218 }
@@ -192,11 +229,10 @@ function renderTopLogprobs() {
192}229}
193230
194/**231/**
195 * onSelectedTokenChanged is called when the user clicks on a token in the232 * 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 the233 * 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.
198 * @param {TokenLogprobs} logprobs - logprob data for the selected token234 * @param {TokenLogprobs} logprobs - logprob data for the selected token
199 * @param {Element} span - target span node that was clicked235 * @param {Node|JQuery} span - target span node that was clicked
200 */236 */
201function onSelectedTokenChanged(logprobs, span) {237function onSelectedTokenChanged(logprobs, span) {
202 $('.logprobs_output_token.selected').removeClass('selected');238 $('.logprobs_output_token.selected').removeClass('selected');
@@ -223,7 +259,10 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
223 }259 }
224260
225 if (getGeneratingApi() === 'openai') {261 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);
227 }266 }
228267
229 const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();268 const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();
@@ -234,79 +273,29 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
234273
235 const prefix = continueFrom || '';274 const prefix = continueFrom || '';
236 const prompt = prefix + tokens.join('');275 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 _);
243}277}
244278
245/**279/**
246 * getTextBeforeClickedWord retrieves the portion of text within a span280 * User clicks on the reroll button in the token output view, or on a word in the
247 * that appears before the word clicked by the user. Using the x and y281 * prefix. Retrieve the prefix for the current message and truncate it at the
248 * coordinates from a PointerEvent, this function identifies the exact282 * 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.
253 *284 *
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 */
258function 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.
287 *286 *
288 * If the user holds the Ctrl key while clicking, only the portion of text287 * @param {number} offset - index of the token in the prefix to reroll from
289 * before the clicked word is retained as the prefix for rerolling288 * @returns {void}
289 * @param offset
290 */290 */
291function onPrefixClicked() {291function onPrefixClicked(offset = undefined) {
292 if (!checkGenerateReady()) {292 if (!checkGenerateReady()) {
293 return;293 return;
294 }294 }
295295
296 const { continueFrom } = getActiveMessageLogprobData();296 const { continueFrom } = getActiveMessageLogprobData() || {};
297 const messageId = chat.length - 1;297 const prefix = continueFrom ? continueFrom.substring(0, offset) : '';
298298 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 _);
310}299}
311300
312function checkGenerateReady() {301function checkGenerateReady() {
@@ -317,6 +306,22 @@ function checkGenerateReady() {
317 return true;306 return true;
318}307}
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 */
315function 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
321/**326/**
322 * onToggleLogprobsPanel is called when the user performs an action that toggles327 * onToggleLogprobsPanel is called when the user performs an action that toggles
@@ -356,8 +361,7 @@ function onToggleLogprobsPanel() {
356}361}
357362
358/**363/**
359 * createSwipe appends a new swipe to the target chat message with the given364 * Appends a new swipe to the target chat message with the given text.
360 * text.
361 * @param {number} messageId - target chat message ID365 * @param {number} messageId - target chat message ID
362 * @param {string} prompt - initial prompt text which will be continued366 * @param {string} prompt - initial prompt text which will be continued
363 */367 */
@@ -399,10 +403,11 @@ function toVisibleWhitespace(input) {
399 * after the span node if its token begins or ends with whitespace in order to403 * after the span node if its token begins or ends with whitespace in order to
400 * allow text to wrap despite whitespace characters being replaced with a dot.404 * allow text to wrap despite whitespace characters being replaced with a dot.
401 * @param {string} text - token text being evaluated for whitespace405 * @param {string} text - token text being evaluated for whitespace
402 * @param {Element} span - target span node to be wrapped406 * @param {Node|JQuery} span - target span node to be wrapped
403 * @returns {Element[]} array of nodes to be appended to the DOM407 * @returns {NodeArray} - array of nodes to be appended to the parent element
404 */408 */
405function withVirtualWhitespace(text, span) {409function withVirtualWhitespace(text, span) {
410 /** @type {NodeArray} */
406 const result = [span];411 const result = [span];
407 if (text.match(/^\s/)) {412 if (text.match(/^\s/)) {
408 result.unshift(document.createTextNode('\u200b'));413 result.unshift(document.createTextNode('\u200b'));
@@ -430,12 +435,16 @@ function withVirtualWhitespace(text, span) {
430}435}
431436
432/**437/**
433 * saveLogprobsForActiveMessage receives an array of TokenLogprobs objects438 * 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 it439 *
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
436 *447 *
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.**
439 * @param {TokenLogprobs[]} logprobs - array of logprobs data for each token448 * @param {TokenLogprobs[]} logprobs - array of logprobs data for each token
440 * @param {string | null} continueFrom - for 'continue' generations, the prompt449 * @param {string | null} continueFrom - for 'continue' generations, the prompt
441 */450 */
@@ -445,7 +454,10 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) {
445 return;454 return;
446 }455 }
447456
448 convertTokenIdLogprobsToText(logprobs);457 // NovelAI only returns token IDs in logprobs data; convert to text tokens in-place
458 if (getGeneratingApi() === 'novel') {
459 convertTokenIdLogprobsToText(logprobs);
460 }
449461
450 const msgId = chat.length - 1;462 const msgId = chat.length - 1;
451 /** @type {MessageLogprobData} */463 /** @type {MessageLogprobData} */
@@ -491,17 +503,18 @@ function getActiveMessageLogprobData() {
491 return state.messageLogprobs.get(hash) || null;503 return state.messageLogprobs.get(hash) || null;
492}504}
493505
506
494/**507/**
495 * convertLogprobTokenIdsToText mutates the given logprobs data's topLogprobs508 * convertLogprobTokenIdsToText replaces token IDs in logprobs data with text tokens,
496 * field keyed by token text instead of token ID. This is only necessary for509 * for APIs that return token IDs instead of text tokens, to wit: NovelAI.
497 * APIs which only return token IDs in their logprobs data; for others this510 *
498 * function is a no-op.
499 * @param {TokenLogprobs[]} input - logprobs data with numeric token IDs511 * @param {TokenLogprobs[]} input - logprobs data with numeric token IDs
500 */512 */
501function convertTokenIdLogprobsToText(input) {513function convertTokenIdLogprobsToText(input) {
502 const api = getGeneratingApi();514 const api = getGeneratingApi();
503 if (api !== 'novel') {515 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');
505 }518 }
506519
507 const tokenizerId = getTokenizerBestMatch(api);520 const tokenizerId = getTokenizerBestMatch(api);
@@ -512,7 +525,8 @@ function convertTokenIdLogprobsToText(input) {
512 )));525 )));
513526
514 // Submit token IDs to tokenizer to get token text, then build ID->text map527 // 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));
516 const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));530 const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));
517531
518 // Fixup logprobs data with token text532 // Fixup logprobs data with token text
@@ -525,9 +539,10 @@ function convertTokenIdLogprobsToText(input) {
525}539}
526540
527export function initLogprobs() {541export function initLogprobs() {
542 REROLL_BUTTON.hide();
528 const debouncedRender = debounce(renderAlternativeTokensView);543 const debouncedRender = debounce(renderAlternativeTokensView);
529 $('#logprobsViewerClose').click(onToggleLogprobsPanel);544 $('#logprobsViewerClose').on('click', onToggleLogprobsPanel);
530 $('#option_toggle_logprobs').click(onToggleLogprobsPanel);545 $('#option_toggle_logprobs').on('click', onToggleLogprobsPanel);
531 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);546 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);
532 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);547 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);
533 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);548 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);