Make dynamic reroll available without use of modifier key
Linting
This commit is contained in:
parent
7c7aaf33fc
commit
5e883e446a
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
convertTokenIdLogprobsToText(logprobs);
|
||||
// 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);
|
||||
|
|
Loading…
Reference in New Issue