Merge branch 'staging' into claude-caching-at-depth

This commit is contained in:
Cohee 2024-11-18 21:03:33 +02:00
commit c3483bc432
18 changed files with 413 additions and 166 deletions

View File

@ -0,0 +1,137 @@
{
"3": {
"inputs": {
"seed": "%seed%",
"steps": "%steps%",
"cfg": "%scale%",
"sampler_name": "%sampler%",
"scheduler": "%scheduler%",
"denoise": "%denoise%",
"model": [
"4",
0
],
"positive": [
"6",
0
],
"negative": [
"7",
0
],
"latent_image": [
"12",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"4": {
"inputs": {
"ckpt_name": "%model%"
},
"class_type": "CheckpointLoaderSimple",
"_meta": {
"title": "Load Checkpoint"
}
},
"6": {
"inputs": {
"text": "%prompt%",
"clip": [
"4",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"7": {
"inputs": {
"text": "%negative_prompt%",
"clip": [
"4",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"3",
0
],
"vae": [
"4",
2
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"9": {
"inputs": {
"filename_prefix": "SillyTavern",
"images": [
"8",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"10": {
"inputs": {
"image": "%char_avatar%"
},
"class_type": "ETN_LoadImageBase64",
"_meta": {
"title": "Load Image (Base64) [https://github.com/Acly/comfyui-tooling-nodes]"
}
},
"12": {
"inputs": {
"pixels": [
"13",
0
],
"vae": [
"4",
2
]
},
"class_type": "VAEEncode",
"_meta": {
"title": "VAE Encode"
}
},
"13": {
"inputs": {
"upscale_method": "bicubic",
"width": "%width%",
"height": "%height%",
"crop": "center",
"image": [
"10",
0
]
},
"class_type": "ImageScale",
"_meta": {
"title": "Upscale Image"
}
}
}

View File

@ -135,6 +135,10 @@
"filename": "Default_Comfy_Workflow.json",
"type": "workflow"
},
{
"filename": "Char_Avatar_Comfy_Workflow.json",
"type": "workflow"
},
{
"filename": "presets/kobold/Ace of Spades.json",
"type": "kobold_preset"

6
package-lock.json generated
View File

@ -3019,9 +3019,9 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
"integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",

View File

@ -72,6 +72,14 @@
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);
}
.logprobs_candidate_list {
grid-row-start: 3;
grid-row-end: 4;

View File

@ -0,0 +1,19 @@
.scrollable-buttons-container {
max-height: 50vh; /* Use viewport height instead of fixed pixels */
overflow-y: auto;
-webkit-overflow-scrolling: touch; /* Momentum scrolling on iOS */
margin-top: 1rem; /* m-t-1 is equivalent to margin-top: 1rem; */
flex-shrink: 1;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
.scrollable-buttons-container::-webkit-scrollbar {
width: 6px;
}
.scrollable-buttons-container::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}

View File

@ -3026,6 +3026,7 @@
<option value="codestral-latest">codestral-latest</option>
<option value="codestral-mamba-latest">codestral-mamba-latest</option>
<option value="pixtral-12b-latest">pixtral-12b-latest</option>
<option value="pixtral-large-latest">pixtral-large-latest</option>
</optgroup>
<optgroup label="Sub-versions">
<option value="open-mistral-nemo-2407">open-mistral-nemo-2407</option>
@ -3040,10 +3041,12 @@
<option value="mistral-medium-2312">mistral-medium-2312</option>
<option value="mistral-large-2402">mistral-large-2402</option>
<option value="mistral-large-2407">mistral-large-2407</option>
<option value="mistral-large-2411">mistral-large-2411</option>
<option value="codestral-2405">codestral-2405</option>
<option value="codestral-2405-blue">codestral-2405-blue</option>
<option value="codestral-mamba-2407">codestral-mamba-2407</option>
<option value="pixtral-12b-2409">pixtral-12b-2409</option>
<option value="pixtral-large-2411">pixtral-large-2411</option>
</optgroup>
<optgroup id="mistralai_other_models" label="Other"></optgroup>
</select>
@ -4503,7 +4506,7 @@
</div>
</div>
<div name="AutoCompleteToggle" class="inline-drawer wide100p flexFlowColumn">
<div class="inline-drawer-toggle inline-drawer-header userSettingsInnerExpandable" title="Options for the various autocompelte input boxes.">
<div class="inline-drawer-toggle inline-drawer-header userSettingsInnerExpandable" title="Options for the various autocomplete input boxes.">
<b><span data-i18n="AutoComplete Settings">AutoComplete Settings</span></b>
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
</div>
@ -6631,8 +6634,11 @@
</div>
</div>
<div class="logprobs_panel_content inline-drawer-content flex-container flexFlowColumn">
<small>
<small class="flex-container alignItemsCenter justifySpaceBetween flexNoWrap">
<b data-i18n="Select a token to see alternatives considered by the AI.">Select a token to see alternatives considered by the AI.</b>
<button id="logprobsReroll" class="menu_button margin0" title="Reroll with the entire prefix" data-i18n="[title]Reroll with the entire prefix">
<span class="fa-solid fa-redo logprobs_reroll"></span>
</button>
</small>
<hr>
<div id="logprobs_generation_output"></div>

View File

@ -703,16 +703,13 @@ const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
*/
function autoFitSendTextArea() {
const originalScrollBottom = chatBlock.scrollHeight - (chatBlock.scrollTop + chatBlock.offsetHeight);
if (Math.ceil(sendTextArea.scrollHeight + 3) >= Math.floor(sendTextArea.offsetHeight)) {
const sendTextAreaMinHeight = '0px';
sendTextArea.style.height = sendTextAreaMinHeight;
}
const newHeight = sendTextArea.scrollHeight + 3;
sendTextArea.style.height = '1px'; // Reset height to 1px to force recalculation of scrollHeight
const newHeight = sendTextArea.scrollHeight;
sendTextArea.style.height = `${newHeight}px`;
if (!isFirefox) {
const newScrollTop = Math.round(chatBlock.scrollHeight - (chatBlock.offsetHeight + originalScrollBottom));
chatBlock.scrollTop = newScrollTop;
chatBlock.scrollTop = chatBlock.scrollHeight - (chatBlock.offsetHeight + originalScrollBottom);
}
}
export const autoFitSendTextAreaDebounced = debounce(autoFitSendTextArea, debounce_timeout.short);

View File

@ -37,6 +37,8 @@
<select id="caption_multimodal_model" class="flex1 text_pole">
<option data-type="mistral" value="pixtral-12b-latest">pixtral-12b-latest</option>
<option data-type="mistral" value="pixtral-12b-2409">pixtral-12b-2409</option>
<option data-type="mistral" value="pixtral-large-latest">pixtral-large-latest</option>
<option data-type="mistral" value="pixtral-large-2411">pixtral-large-2411</option>
<option data-type="zerooneai" value="yi-vision">yi-vision</option>
<option data-type="openai" value="gpt-4-vision-preview">gpt-4-vision-preview</option>
<option data-type="openai" value="gpt-4-turbo">gpt-4-turbo</option>

View File

@ -17,6 +17,7 @@
<li data-placeholder="scheduler" class="sd_comfy_workflow_editor_not_found">"%scheduler%"</li>
<li data-placeholder="steps" class="sd_comfy_workflow_editor_not_found">"%steps%"</li>
<li data-placeholder="scale" class="sd_comfy_workflow_editor_not_found">"%scale%"</li>
<li data-placeholder="denoise" class="sd_comfy_workflow_editor_not_found">"%denoise%"</li>
<li data-placeholder="clip_skip" class="sd_comfy_workflow_editor_not_found">"%clip_skip%"</li>
<li data-placeholder="width" class="sd_comfy_workflow_editor_not_found">"%width%"</li>
<li data-placeholder="height" class="sd_comfy_workflow_editor_not_found">"%height%"</li>

View File

@ -3269,6 +3269,10 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
const denoising_strength = extension_settings.sd.denoising_strength === undefined ? 1.0 : extension_settings.sd.denoising_strength;
workflow = workflow.replaceAll('"%denoise%"', JSON.stringify(denoising_strength));
placeholders.forEach(ph => {
workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
});
@ -3279,7 +3283,8 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
const response = await fetch(getUserAvatarUrl());
if (response.ok) {
const avatarBlob = await response.blob();
const avatarBase64 = await getBase64Async(avatarBlob);
const avatarBase64DataUrl = await getBase64Async(avatarBlob);
const avatarBase64 = avatarBase64DataUrl.split(',')[1];
workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
} else {
workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
@ -3289,7 +3294,8 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
const response = await fetch(getCharacterAvatarUrl());
if (response.ok) {
const avatarBlob = await response.blob();
const avatarBase64 = await getBase64Async(avatarBlob);
const avatarBase64DataUrl = await getBase64Async(avatarBlob);
const avatarBase64 = avatarBase64DataUrl.split(',')[1];
workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
} else {
workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));

View File

@ -319,7 +319,7 @@
<input class="neo-range-input" type="number" id="sd_hr_scale_value" data-for="sd_hr_scale" min="{{hr_scale_min}}" max="{{hr_scale_max}}" step="{{hr_scale_step}}" value="{{hr_scale}}" >
</div>
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad">
<div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy">
<small>
<span data-i18n="Denoising strength">Denoising strength</span>
</small>

View File

@ -23,7 +23,7 @@ import {
import { collapseNewlines, registerDebugFunction } from '../../power-user.js';
import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
import { getDataBankAttachments, getDataBankAttachmentsForSource, getFileAttachment } from '../../chats.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive, trimToStartSentence, trimToEndSentence } from '../../utils.js';
import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive, trimToStartSentence, trimToEndSentence, escapeHtml } from '../../utils.js';
import { debounce_timeout } from '../../constants.js';
import { getSortedEntries } from '../../world-info.js';
import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
@ -44,6 +44,9 @@ const MODULE_NAME = 'vectors';
export const EXTENSION_PROMPT_TAG = '3_vectors';
export const EXTENSION_PROMPT_TAG_DB = '4_vectors_data_bank';
// Force solo chunks for sources that don't support batching.
const getBatchSize = () => ['transformers', 'palm', 'ollama'].includes(settings.source) ? 1 : 5;
const settings = {
// For both
source: 'transformers',
@ -125,7 +128,7 @@ async function onVectorizeAllClick() {
// upon request of a full vectorise
cachedSummaries.clear();
const batchSize = 5;
const batchSize = getBatchSize();
const elapsedLog = [];
let finished = false;
$('#vectorize_progress').show();
@ -560,7 +563,9 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overla
fileText = translatedText;
}
const toast = toastr.info('Vectorization may take some time, please wait...', `Ingesting file ${fileName}`);
const batchSize = getBatchSize();
const toastBody = $('<span>').text('This may take a while. Please wait...');
const toast = toastr.info(toastBody, `Ingesting file ${escapeHtml(fileName)}`, { closeButton: false, escapeHtml: false, timeOut: 0, extendedTimeOut: 0 });
const overlapSize = Math.round(chunkSize * overlapPercent / 100);
const delimiters = getChunkDelimiters();
// Overlap should not be included in chunk size. It will be later compensated by overlapChunks
@ -569,7 +574,12 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overla
console.debug(`Vectors: Split file ${fileName} into ${chunks.length} chunks with ${overlapPercent}% overlap`, chunks);
const items = chunks.map((chunk, index) => ({ hash: getStringHash(chunk), text: chunk, index: index }));
await insertVectorItems(collectionId, items);
for (let i = 0; i < items.length; i += batchSize) {
toastBody.text(`${i}/${items.length} (${Math.round((i / items.length) * 100)}%) chunks processed`);
const chunkedBatch = items.slice(i, i + batchSize);
await insertVectorItems(collectionId, chunkedBatch);
}
toastr.clear(toast);
console.log(`Vectors: Inserted ${chunks.length} vector items for file ${fileName} into ${collectionId}`);
@ -1050,7 +1060,7 @@ async function onViewStatsClick() {
toastr.info(`Total hashes: <b>${totalHashes}</b><br>
Unique hashes: <b>${uniqueHashes}</b><br><br>
I'll mark collected messages with a green circle.`,
`Stats for chat ${chatId}`,
`Stats for chat ${escapeHtml(chatId)}`,
{ timeOut: 10000, escapeHtml: false },
);

View File

@ -1,6 +1,5 @@
import {
animation_duration,
callPopup,
chat,
cleanUpMessage,
event_types,
@ -13,15 +12,22 @@ 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;
const REROLL_BUTTON = $('#logprobsReroll');
/**
* Tuple of a candidate token and its logarithm of probability of being chosen
* @typedef {[string, number]} Candidate - (token, logprob)
*/
/**
* @typedef {(Node|JQuery<Text>|JQuery<HTMLElement>)[]} NodeArray - Array of DOM nodes
*/
/**
* Logprob data for a single message
* @typedef {Object} MessageLogprobData
@ -43,17 +49,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 +83,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;
@ -82,16 +98,34 @@ function renderAlternativeTokensView() {
const prefix = continueFrom || '';
const tokenSpans = [];
REROLL_BUTTON.toggle(!!prefix);
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));
REROLL_BUTTON.off('click').on('click', () => onPrefixClicked(prefix.length));
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) => {
@ -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];
}
});
@ -167,15 +204,15 @@ function renderTopLogprobs() {
container.addClass('selected');
}
const tokenText = $('<span></span>').text(`${toVisibleWhitespace(token)}`);
const percentText = $('<span></span>').text(`${(probability * 100).toFixed(2)}%`);
const tokenText = $('<span></span>').text(`${toVisibleWhitespace(token.toString())}`);
const percentText = $('<span></span>').text(`${(+probability * 100).toFixed(2)}%`);
container.append(tokenText, percentText);
if (log) {
container.attr('title', `logarithm: ${log}`);
}
addKeyboardProps(container);
if (token !== '<others>') {
container.click(() => onAlternativeClicked(state.selectedTokenLogprobs, token));
container.on('click', () => onAlternativeClicked(state.selectedTokenLogprobs, token.toString()));
} else {
container.prop('disabled', true);
}
@ -192,11 +229,10 @@ 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
* @param {Node|JQuery} span - target span node that was clicked
*/
function onSelectedTokenChanged(logprobs, span) {
$('.logprobs_output_token.selected').removeClass('selected');
@ -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,15 +361,14 @@ 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
*/
function createSwipe(messageId, prompt) {
// need to call `cleanUpMessage` on our new prompt, because we were working
// with raw model output and our new prompt is missing trimming/macro replacements
const cleanedPrompt = cleanUpMessage(prompt, false, false);
const cleanedPrompt = cleanUpMessage(prompt, false, false, true);
const msg = chat[messageId];
const newSwipeInfo = {
@ -399,10 +403,11 @@ function toVisibleWhitespace(input) {
* after the span node if its token begins or ends with whitespace in order to
* 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
* @param {Node|JQuery} span - target span node to be wrapped
* @returns {NodeArray} - array of nodes to be appended to the parent element
*/
function withVirtualWhitespace(text, span) {
/** @type {NodeArray} */
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,7 +525,8 @@ function convertTokenIdLogprobsToText(input) {
)));
// Submit token IDs to tokenizer to get token text, then build ID->text map
const { chunks } = decodeTextTokens(tokenizerId, tokenIds);
// noinspection JSCheckFunctionSignatures - mutates input in-place
const { chunks } = decodeTextTokens(tokenizerId, tokenIds.map(parseInt));
const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));
// Fixup logprobs data with token text
@ -525,9 +539,10 @@ function convertTokenIdLogprobsToText(input) {
}
export function initLogprobs() {
REROLL_BUTTON.hide();
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);

View File

@ -4165,7 +4165,7 @@ async function onModelChange() {
$('#openai_max_context').attr('max', unlocked_max);
} else if (oai_settings.mistralai_model.includes('codestral-mamba')) {
$('#openai_max_context').attr('max', max_256k);
} else if (['mistral-large-2407', 'mistral-large-latest'].includes(oai_settings.mistralai_model)) {
} else if (['mistral-large-2407', 'mistral-large-2411', 'mistral-large-latest'].includes(oai_settings.mistralai_model)) {
$('#openai_max_context').attr('max', max_128k);
} else if (oai_settings.mistralai_model.includes('mistral-nemo')) {
$('#openai_max_context').attr('max', max_128k);
@ -4764,6 +4764,8 @@ export function isImageInliningSupported() {
'pixtral-12b-latest',
'pixtral-12b',
'pixtral-12b-2409',
'pixtral-large-latest',
'pixtral-large-2411',
];
switch (oai_settings.chat_completion_source) {

View File

@ -2083,7 +2083,10 @@ async function buttonsCallback(args, text) {
let popup;
const buttonContainer = document.createElement('div');
buttonContainer.classList.add('flex-container', 'flexFlowColumn', 'wide100p', 'm-t-1');
buttonContainer.classList.add('flex-container', 'flexFlowColumn', 'wide100p');
const scrollableContainer = document.createElement('div');
scrollableContainer.classList.add('scrollable-buttons-container');
for (const [result, button] of resultToButtonMap) {
const buttonElement = document.createElement('div');
@ -2096,9 +2099,16 @@ async function buttonsCallback(args, text) {
buttonContainer.appendChild(buttonElement);
}
scrollableContainer.appendChild(buttonContainer);
const popupContainer = document.createElement('div');
popupContainer.innerHTML = safeValue;
popupContainer.appendChild(buttonContainer);
popupContainer.appendChild(scrollableContainer);
// Ensure the popup uses flex layout
popupContainer.style.display = 'flex';
popupContainer.style.flexDirection = 'column';
popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'Cancel', allowVerticalScrolling: true });
popup.show()

View File

@ -23,29 +23,42 @@ export let openRouterModels = [];
const OPENROUTER_PROVIDERS = [
'OpenAI',
'Anthropic',
'HuggingFace',
'Google',
'Mancer',
'Mancer 2',
'Google AI Studio',
'Groq',
'SambaNova',
'Cohere',
'Mistral',
'Together',
'Together 2',
'Fireworks',
'DeepInfra',
'Lepton',
'Novita',
'Avian',
'Lambda',
'Azure',
'Modal',
'AnyScale',
'Replicate',
'Perplexity',
'Recursal',
'Fireworks',
'Mistral',
'Groq',
'Cohere',
'Lepton',
'OctoAI',
'Novita',
'Lynn',
'Lynn 2',
'DeepSeek',
'Infermatic',
'AI21',
'Featherless',
'Inflection',
'xAI',
'01.AI',
'HuggingFace',
'Mancer',
'Mancer 2',
'Hyperbolic',
'Hyperbolic 2',
'Lynn 2',
'Lynn',
'Reflection',
];
export async function loadOllamaModels(data) {

View File

@ -9,6 +9,7 @@
@import url(css/logprobs.css);
@import url(css/accounts.css);
@import url(css/tags.css);
@import url(css/scrollable-button.css);
:root {
--doc-height: 100%;

View File

@ -7,7 +7,7 @@ import sanitize from 'sanitize-filename';
import { sync as writeFileAtomicSync } from 'write-file-atomic';
import FormData from 'form-data';
import { getBasicAuthHeader, delay } from '../util.js';
import { delay, getBasicAuthHeader, tryParse } from '../util.js';
import { jsonParser } from '../express-common.js';
import { readSecret, SECRET_KEYS } from './secrets.js';
@ -19,7 +19,7 @@ import { readSecret, SECRET_KEYS } from './secrets.js';
function getComfyWorkflows(directories) {
return fs
.readdirSync(directories.comfyWorkflows)
.filter(file => file[0] != '.' && file.toLowerCase().endsWith('.json'))
.filter(file => file[0] !== '.' && file.toLowerCase().endsWith('.json'))
.sort(Intl.Collator().compare);
}
@ -67,8 +67,7 @@ router.post('/upscalers', jsonParser, async (request, response) => {
/** @type {any} */
const data = await result.json();
const names = data.map(x => x.name);
return names;
return data.map(x => x.name);
}
async function getLatentUpscalers() {
@ -88,8 +87,7 @@ router.post('/upscalers', jsonParser, async (request, response) => {
/** @type {any} */
const data = await result.json();
const names = data.map(x => x.name);
return names;
return data.map(x => x.name);
}
const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]);
@ -241,8 +239,7 @@ router.post('/set-model', jsonParser, async (request, response) => {
'Authorization': getBasicAuthHeader(request.body.auth),
},
});
const data = await result.json();
return data;
return await result.json();
}
const url = new URL(request.body.url);
@ -274,7 +271,7 @@ router.post('/set-model', jsonParser, async (request, response) => {
const progress = progressState['progress'];
const jobCount = progressState['state']['job_count'];
if (progress == 0.0 && jobCount === 0) {
if (progress === 0.0 && jobCount === 0) {
break;
}
@ -412,8 +409,19 @@ comfy.post('/models', jsonParser, async (request, response) => {
}
/** @type {any} */
const data = await result.json();
return response.send(data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })));
} catch (error) {
const ckpts = data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })) || [];
const unets = data.UNETLoader.input.required.unet_name[0].map(it => ({ value: it, text: `UNet: ${it}` })) || [];
// load list of GGUF unets from diffusion_models if the loader node is available
const ggufs = data.UnetLoaderGGUF?.input.required.unet_name[0].map(it => ({ value: it, text: `GGUF: ${it}` })) || [];
const models = [...ckpts, ...unets, ...ggufs];
// make the display names of the models somewhat presentable
models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' '));
return response.send(models);
} catch (error) {
console.log(error);
return response.sendStatus(500);
}
@ -527,7 +535,8 @@ comfy.post('/generate', jsonParser, async (request, response) => {
body: request.body.prompt,
});
if (!promptResult.ok) {
throw new Error('ComfyUI returned an error.');
const text = await promptResult.text();
throw new Error('ComfyUI returned an error.', { cause: tryParse(text) });
}
/** @type {any} */
@ -550,7 +559,13 @@ comfy.post('/generate', jsonParser, async (request, response) => {
await delay(100);
}
if (item.status.status_str === 'error') {
throw new Error('ComfyUI generation did not succeed.');
// Report node tracebacks if available
const errorMessages = item.status?.messages
?.filter(it => it[0] === 'execution_error')
.map(it => it[1])
.map(it => `${it.node_type} [${it.node_id}] ${it.exception_type}: ${it.exception_message}`)
.join('\n') || '';
throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim());
}
const imgInfo = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0];
const imgUrl = new URL(request.body.url);
@ -560,11 +575,12 @@ comfy.post('/generate', jsonParser, async (request, response) => {
if (!imgResponse.ok) {
throw new Error('ComfyUI returned an error.');
}
const imgBuffer = await imgResponse.buffer();
return response.send(imgBuffer.toString('base64'));
const imgBuffer = await imgResponse.arrayBuffer();
return response.send(Buffer.from(imgBuffer).toString('base64'));
} catch (error) {
console.log(error);
return response.sendStatus(500);
console.log('ComfyUI error:', error);
response.status(500).send(error.message);
return response;
}
});