Make dynamic reroll available without use of modifier key

Linting
This commit is contained in:
ceruleandeep 2024-11-17 00:36:06 +11:00
parent 7c7aaf33fc
commit 5e883e446a
2 changed files with 137 additions and 109 deletions

View File

@ -72,6 +72,20 @@
opacity: 0.5;
}
.logprobs_output_prefix:hover {
background-color: rgba(255, 0, 50, 0.4);
}
.logprobs_output_prefix:hover ~ .logprobs_output_prefix {
background-color: rgba(255, 0, 50, 0.4);
}
#logprobsReroll {
float: right; /* Position the button to the right */
margin: 5px 0 5px 10px; /* Add spacing (top, right, bottom, left) */
clear: right; /* Ensure it starts on a new line */
}
.logprobs_candidate_list {
grid-row-start: 3;
grid-row-end: 4;

View File

@ -1,6 +1,5 @@
import {
animation_duration,
callPopup,
chat,
cleanUpMessage,
event_types,
@ -13,6 +12,8 @@ import {
import { debounce, delay, getStringHash } from './utils.js';
import { decodeTextTokens, getTokenizerBestMatch } from './tokenizers.js';
import { power_user } from './power-user.js';
import { callGenericPopup, POPUP_TYPE } from './popup.js';
import { t } from './i18n.js';
const TINTS = 4;
const MAX_MESSAGE_LOGPROBS = 100;
@ -43,17 +44,26 @@ const MAX_MESSAGE_LOGPROBS = 100;
* @property {Candidate[]} topLogprobs - Array of top candidate tokens
*/
let state = {
/** @type {TokenLogprobs | null} */
/**
* State object for Token Probabilities
* @typedef {Object} LogprobsState
* @property {?TokenLogprobs} selectedTokenLogprobs Log probabilities for
* currently-selected token.
* @property {Map<number, MessageLogprobData>} messageLogprobs Log probabilities for
* each message, keyed by message hash.
*/
/**
* @type {LogprobsState} state
*/
const state = {
selectedTokenLogprobs: null,
/** @type {Map<number, MessageLogprobData>} */
messageLogprobs: new Map(),
};
/**
* renderAlternativeTokensView renders the Token Probabilities UI and all
* subviews with the active message's logprobs data. If the message has no token
* logprobs, a zero-state is rendered.
* Renders the Token Probabilities UI and all subviews with the active message's
* logprobs data. If the message has no token logprobs, a message is displayed.
*/
function renderAlternativeTokensView() {
const view = $('#logprobs_generation_output');
@ -68,13 +78,14 @@ function renderAlternativeTokensView() {
const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;
if (!messageLogprobs?.length || usingSmoothStreaming) {
const emptyState = $('<div></div>');
const noTokensMsg = usingSmoothStreaming
? 'Token probabilities are not available when using Smooth Streaming.'
: 'No token probabilities available for the current message.';
const msg = power_user.request_token_probabilities
? noTokensMsg
: '<span>Enable <b>Request token probabilities</b> in the User Settings menu to use this feature.</span>';
emptyState.html(msg);
const noTokensMsg = !power_user.request_token_probabilities
? '<span>Enable <b>Request token probabilities</b> in the User Settings menu to use this feature.</span>'
: usingSmoothStreaming
? t`Token probabilities are not available when using Smooth Streaming.`
: is_send_press
? t`Generation in progress...`
: t`No token probabilities available for the current message.`;
emptyState.html(noTokensMsg);
emptyState.addClass('logprobs_empty_state');
view.append(emptyState);
return;
@ -84,16 +95,39 @@ function renderAlternativeTokensView() {
const tokenSpans = [];
if (prefix) {
const prefixSpan = $('<span></span>');
prefixSpan.text(prefix);
prefixSpan.html(prefixSpan.html().replace(/\n/g, '<br>'));
prefixSpan.addClass('logprobs_output_prefix');
prefixSpan.attr('title', 'Select to reroll the last \'Continue\' generation.\nHold the CTRL key when clicking to reroll from before that word.');
prefixSpan.click(onPrefixClicked);
addKeyboardProps(prefixSpan);
tokenSpans.push(...withVirtualWhitespace(prefix, prefixSpan));
const rerollButton = $('<button id="logprobsReroll" class="menu_button">' +
' <span class="fa-solid fa-redo logprobs_reroll"></span>' +
'</button>');
rerollButton.attr('title', t`Reroll with the entire prefix`);
rerollButton.on('click', () => onPrefixClicked(prefix.length));
tokenSpans.push(rerollButton);
let cumulativeOffset = 0;
const words = prefix.split(/\s+/);
const delimiters = prefix.match(/\s+/g) || []; // Capture the actual delimiters
words.forEach((word, i) => {
const span = $('<span></span>');
span.text(`${word} `);
span.addClass('logprobs_output_prefix');
span.attr('title', t`Reroll from this point`);
let offset = cumulativeOffset;
span.on('click', () => onPrefixClicked(offset));
addKeyboardProps(span);
tokenSpans.push(span);
tokenSpans.push(delimiters[i]?.includes('\n')
? document.createElement('br')
: document.createTextNode(delimiters[i] || ' '),
);
cumulativeOffset += word.length + (delimiters[i]?.length || 0);
});
}
messageLogprobs.forEach((tokenData, i) => {
const { token } = tokenData;
const span = $('<span></span>');
@ -101,7 +135,7 @@ function renderAlternativeTokensView() {
span.text(text);
span.addClass('logprobs_output_token');
span.addClass('logprobs_tint_' + (i % TINTS));
span.click(() => onSelectedTokenChanged(tokenData, span));
span.on('click', () => onSelectedTokenChanged(tokenData, span));
addKeyboardProps(span);
tokenSpans.push(...withVirtualWhitespace(token, span));
});
@ -129,6 +163,10 @@ function addKeyboardProps(element) {
/**
* renderTopLogprobs renders the top logprobs subview with the currently
* selected token highlighted. If no token is selected, the subview is hidden.
*
* Callers:
* - renderAlternativeTokensView, to render the entire view
* - onSelectedTokenChanged, to update the view when a token is selected
*/
function renderTopLogprobs() {
$('#logprobs_top_logprobs_hint').hide();
@ -150,8 +188,7 @@ function renderTopLogprobs() {
const probability = Math.exp(log);
sum += probability;
return [text, probability, log];
}
else {
} else {
return [text, log, null];
}
});
@ -175,7 +212,7 @@ function renderTopLogprobs() {
}
addKeyboardProps(container);
if (token !== '<others>') {
container.click(() => onAlternativeClicked(state.selectedTokenLogprobs, token));
container.on('click', () => onAlternativeClicked(state.selectedTokenLogprobs, token));
} else {
container.prop('disabled', true);
}
@ -192,9 +229,8 @@ function renderTopLogprobs() {
}
/**
* onSelectedTokenChanged is called when the user clicks on a token in the
* token output view. It updates the selected token state and re-renders the
* top logprobs view, or deselects the token if it was already selected.
* User clicks on a token in the token output view. It updates the selected token state
* and re-renders the top logprobs view, or deselects the token if it was already selected.
* @param {TokenLogprobs} logprobs - logprob data for the selected token
* @param {Element} span - target span node that was clicked
*/
@ -223,7 +259,10 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
}
if (getGeneratingApi() === 'openai') {
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');
const title = t`Feature unavailable`;
const message = t`Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.`;
const content = `<h3>${title}</h3><p>${message}</p>`;
return callGenericPopup(content, POPUP_TYPE.TEXT);
}
const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();
@ -234,79 +273,29 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
const prefix = continueFrom || '';
const prompt = prefix + tokens.join('');
const messageId = chat.length - 1;
createSwipe(messageId, prompt);
$('.swipe_right:last').click(); // :see_no_evil:
Generate('continue').then(_ => void _);
addGeneration(prompt);
}
/**
* getTextBeforeClickedWord retrieves the portion of text within a span
* that appears before the word clicked by the user. Using the x and y
* coordinates from a PointerEvent, this function identifies the exact
* word clicked and returns the text preceding it within the span.
* User clicks on the reroll button in the token output view, or on a word in the
* prefix. Retrieve the prefix for the current message and truncate it at the
* offset for the selected word. Then request a `continue` completion from the
* model with the new prompt.
*
* If the clicked position does not resolve to a valid word or text node,
* the entire span text is returned as a fallback.
* If no offset is provided, the entire prefix will be rerolled.
*
* @param {PointerEvent} event - The click event containing the x and y coordinates.
* @param {string} spanText - The full text content of the span element.
* @returns {string} The text before the clicked word, or the entire span text as fallback.
* @param {number} offset - index of the token in the prefix to reroll from
* @returns {void}
* @param offset
*/
function getTextBeforeClickedWord(event, spanText) {
const x = event.clientX;
const y = event.clientY;
const range = document.caretRangeFromPoint(x, y);
if (range && range.startContainer.nodeType === Node.TEXT_NODE) {
const textNode = range.startContainer;
const offset = range.startOffset;
// Get the full text content of the text node
const text = textNode.nodeValue;
// Find the boundaries of the clicked word
const start = text.lastIndexOf(' ', offset - 1) + 1;
// Return the text before the clicked word
return text.slice(0, start);
}
// If we can't determine the exact word, return the full span text as a fallback
return spanText;
}
/**
* onPrefixClicked is called when the user clicks on the carried-over prefix
* in the token output view. It allows them to reroll the last 'continue'
* completion with none of the output generated from it, in case they don't
* like the results.
*
* If the user holds the Ctrl key while clicking, only the portion of text
* before the clicked word is retained as the prefix for rerolling
*/
function onPrefixClicked() {
function onPrefixClicked(offset = undefined) {
if (!checkGenerateReady()) {
return;
}
const { continueFrom } = getActiveMessageLogprobData();
const messageId = chat.length - 1;
// Check if Ctrl key is pressed during the click
let prefix = continueFrom || '';
if (event.ctrlKey) {
// Ctrl is pressed - use the text before the clicked word
prefix = getTextBeforeClickedWord(event, continueFrom);
}
// Use the determined `prefix`
createSwipe(messageId, prefix);
$('.swipe_right:last').click();
Generate('continue').then(_ => void _);
const { continueFrom } = getActiveMessageLogprobData() || {};
const prefix = continueFrom ? continueFrom.substring(0, offset) : '';
addGeneration(prefix);
}
function checkGenerateReady() {
@ -317,6 +306,22 @@ function checkGenerateReady() {
return true;
}
/**
* Generates a new swipe as a continuation of the given prompt, when user selects
* an alternative token or rerolls from a prefix.
*
* @param prompt
*/
function addGeneration(prompt) {
const messageId = chat.length - 1;
if (prompt && prompt.length > 0) {
createSwipe(messageId, prompt);
$('.swipe_right:last').trigger('click');
void Generate('continue');
} else {
$('.swipe_right:last').trigger('click');
}
}
/**
* onToggleLogprobsPanel is called when the user performs an action that toggles
@ -356,8 +361,7 @@ function onToggleLogprobsPanel() {
}
/**
* createSwipe appends a new swipe to the target chat message with the given
* text.
* Appends a new swipe to the target chat message with the given text.
* @param {number} messageId - target chat message ID
* @param {string} prompt - initial prompt text which will be continued
*/
@ -400,9 +404,10 @@ function toVisibleWhitespace(input) {
* allow text to wrap despite whitespace characters being replaced with a dot.
* @param {string} text - token text being evaluated for whitespace
* @param {Element} span - target span node to be wrapped
* @returns {Element[]} array of nodes to be appended to the DOM
* @returns {Node[]} - array of nodes to be appended to the parent element
*/
function withVirtualWhitespace(text, span) {
/** @type {Node[]} */
const result = [span];
if (text.match(/^\s/)) {
result.unshift(document.createTextNode('\u200b'));
@ -430,12 +435,16 @@ function withVirtualWhitespace(text, span) {
}
/**
* saveLogprobsForActiveMessage receives an array of TokenLogprobs objects
* representing the top logprobs for each token in a message and associates it
* with the active message.
* Receives the top logprobs for each token in a message and associates it with the active message.
*
* Ensure the active message has been updated and rendered before calling this function
* or the logprobs data will be saved to the wrong message.
*
* Callers:
* - Generate:onSuccess via saveLogprobsForActiveMessage, for non-streaming text completion
* - StreamingProcessor:onFinishStreaming, for streaming text completion
* - sendOpenAIRequest, for non-streaming chat completion
*
* **Ensure the active message has been updated and rendered before calling
* this function or the logprobs data will be saved to the wrong message.**
* @param {TokenLogprobs[]} logprobs - array of logprobs data for each token
* @param {string | null} continueFrom - for 'continue' generations, the prompt
*/
@ -445,7 +454,10 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) {
return;
}
// NovelAI only returns token IDs in logprobs data; convert to text tokens in-place
if (getGeneratingApi() === 'novel') {
convertTokenIdLogprobsToText(logprobs);
}
const msgId = chat.length - 1;
/** @type {MessageLogprobData} */
@ -491,17 +503,18 @@ function getActiveMessageLogprobData() {
return state.messageLogprobs.get(hash) || null;
}
/**
* convertLogprobTokenIdsToText mutates the given logprobs data's topLogprobs
* field keyed by token text instead of token ID. This is only necessary for
* APIs which only return token IDs in their logprobs data; for others this
* function is a no-op.
* convertLogprobTokenIdsToText replaces token IDs in logprobs data with text tokens,
* for APIs that return token IDs instead of text tokens, to wit: NovelAI.
*
* @param {TokenLogprobs[]} input - logprobs data with numeric token IDs
*/
function convertTokenIdLogprobsToText(input) {
const api = getGeneratingApi();
if (api !== 'novel') {
return input;
// should have been checked by the caller
throw new Error('convertTokenIdLogprobsToText should only be called for NovelAI');
}
const tokenizerId = getTokenizerBestMatch(api);
@ -512,6 +525,7 @@ function convertTokenIdLogprobsToText(input) {
)));
// Submit token IDs to tokenizer to get token text, then build ID->text map
// noinspection JSCheckFunctionSignatures - mutates input in-place
const { chunks } = decodeTextTokens(tokenizerId, tokenIds);
const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));
@ -526,8 +540,8 @@ function convertTokenIdLogprobsToText(input) {
export function initLogprobs() {
const debouncedRender = debounce(renderAlternativeTokensView);
$('#logprobsViewerClose').click(onToggleLogprobsPanel);
$('#option_toggle_logprobs').click(onToggleLogprobsPanel);
$('#logprobsViewerClose').on('click', onToggleLogprobsPanel);
$('#option_toggle_logprobs').on('click', onToggleLogprobsPanel);
eventSource.on(event_types.CHAT_CHANGED, debouncedRender);
eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);
eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);