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 @@
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
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
75.logprobs_candidate_list {89.logprobs_candidate_list {
76 grid-row-start: 3;90 grid-row-start: 3;
77 grid-row-end: 4;91 grid-row-end: 4;
public/scripts/logprobs.js+122 -108
@@ -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,6 +12,8 @@ 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;
@@ -43,17 +44,26 @@ const MAX_MESSAGE_LOGPROBS = 100;
43 * @property {Candidate[]} topLogprobs - Array of top candidate tokens44 * @property {Candidate[]} topLogprobs - Array of top candidate tokens
44 */45 */
4546
46let 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 */
59const state = {
48 selectedTokenLogprobs: null,60 selectedTokenLogprobs: null,
49 /** @type {Map<number, MessageLogprobData>} */
50 messageLogprobs: new Map(),61 messageLogprobs: new Map(),
51};62};
5263
53/**64/**
54 * renderAlternativeTokensView renders the Token Probabilities UI and all65 * 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 token66 * logprobs data. If the message has no token logprobs, a message is displayed.
56 * logprobs, a zero-state is rendered.
57 */67 */
58function renderAlternativeTokensView() {68function renderAlternativeTokensView() {
59 const view = $('#logprobs_generation_output');69 const view = $('#logprobs_generation_output');
@@ -68,13 +78,14 @@ function renderAlternativeTokensView() {
68 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;78 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;
69 if (!messageLogprobs?.length || usingSmoothStreaming) {79 if (!messageLogprobs?.length || usingSmoothStreaming) {
70 const emptyState = $('<div></div>');80 const emptyState = $('<div></div>');
71 const noTokensMsg = usingSmoothStreaming81 const noTokensMsg = !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_probabilities84 ? t`Token probabilities are not available when using Smooth Streaming.`
75 ? noTokensMsg85 : 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);
78 emptyState.addClass('logprobs_empty_state');89 emptyState.addClass('logprobs_empty_state');
79 view.append(emptyState);90 view.append(emptyState);
80 return;91 return;
@@ -84,16 +95,39 @@ function renderAlternativeTokensView() {
84 const tokenSpans = [];95 const tokenSpans = [];
8596
86 if (prefix) {97 if (prefix) {
87 const prefixSpan = $('<span></span>');98 const rerollButton = $('<button 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));
92 prefixSpan.click(onPrefixClicked);103 tokenSpans.push(rerollButton);
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 });
95 }128 }
96129
130
97 messageLogprobs.forEach((tokenData, i) => {131 messageLogprobs.forEach((tokenData, i) => {
98 const { token } = tokenData;132 const { token } = tokenData;
99 const span = $('<span></span>');133 const span = $('<span></span>');
@@ -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 });
@@ -175,7 +212,7 @@ function renderTopLogprobs() {
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));
179 } else {216 } else {
180 container.prop('disabled', true);217 container.prop('disabled', true);
181 }218 }
@@ -192,9 +229,8 @@ 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 {Element} span - target span node that was clicked
200 */236 */
@@ -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 */
@@ -400,9 +404,10 @@ function toVisibleWhitespace(input) {
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 {Element} span - target span node to be wrapped
403 * @returns {Element[]} array of nodes to be appended to the DOM407 * @returns {Node[]} - array of nodes to be appended to the parent element
404 */408 */
405function withVirtualWhitespace(text, span) {409function withVirtualWhitespace(text, span) {
410 /** @type {Node[]} */
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
457 // NovelAI only returns token IDs in logprobs data; convert to text tokens in-place
458 if (getGeneratingApi() === 'novel') {
448 convertTokenIdLogprobsToText(logprobs);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,6 +525,7 @@ 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
528 // noinspection JSCheckFunctionSignatures - mutates input in-place
515 const { chunks } = decodeTextTokens(tokenizerId, tokenIds);529 const { chunks } = decodeTextTokens(tokenizerId, tokenIds);
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
@@ -526,8 +540,8 @@ function convertTokenIdLogprobsToText(input) {
526540
527export function initLogprobs() {541export function initLogprobs() {
528 const debouncedRender = debounce(renderAlternativeTokensView);542 const debouncedRender = debounce(renderAlternativeTokensView);
529 $('#logprobsViewerClose').click(onToggleLogprobsPanel);543 $('#logprobsViewerClose').on('click', onToggleLogprobsPanel);
530 $('#option_toggle_logprobs').click(onToggleLogprobsPanel);544 $('#option_toggle_logprobs').on('click', onToggleLogprobsPanel);
531 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);545 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);
532 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);546 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);
533 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);547 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);