From 5e883e446a20a5a8b074a28eaee111ba6deb3ee5 Mon Sep 17 00:00:00 2001 From: ceruleandeep Date: Sun, 17 Nov 2024 00:36:06 +1100 Subject: [PATCH] Make dynamic reroll available without use of modifier key Linting --- public/css/logprobs.css | 14 +++ public/scripts/logprobs.js | 232 ++++++++++++++++++++----------------- 2 files changed, 137 insertions(+), 109 deletions(-) diff --git a/public/css/logprobs.css b/public/css/logprobs.css index 91a716c7d..f9b5e6b42 100644 --- a/public/css/logprobs.css +++ b/public/css/logprobs.css @@ -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; diff --git a/public/scripts/logprobs.js b/public/scripts/logprobs.js index e82294782..49007a9bf 100644 --- a/public/scripts/logprobs.js +++ b/public/scripts/logprobs.js @@ -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} messageLogprobs Log probabilities for + * each message, keyed by message hash. + */ + +/** + * @type {LogprobsState} state + */ +const state = { selectedTokenLogprobs: null, - /** @type {Map} */ 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 = $('
'); - 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 - : 'Enable Request token probabilities in the User Settings menu to use this feature.'; - emptyState.html(msg); + const noTokensMsg = !power_user.request_token_probabilities + ? 'Enable Request token probabilities in the User Settings menu to use this feature.' + : 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 = $(''); - prefixSpan.text(prefix); - prefixSpan.html(prefixSpan.html().replace(/\n/g, '
')); - 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 = $(''); + 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.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 = $(''); @@ -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 !== '') { - 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('

Feature unavailable

Due to API limitations, rerolling a token is not supported with OpenAI. Try switching to a different API.

', '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 = `

${title}

${message}

`; + 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);