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

c3483bc4323c40cf3a936c5ae4fbd3c47fabe219

Cohee <18619528+Cohee1207@users.noreply.github.com>

18 files changed, +413 -166Ignore whitespace
default/content/Char_Avatar_Comfy_Workflow.json+137 -0
@@ -0,0 +1,137 @@
1+{
2+ "3": {
3+ "inputs": {
4+ "seed": "%seed%",
5+ "steps": "%steps%",
6+ "cfg": "%scale%",
7+ "sampler_name": "%sampler%",
8+ "scheduler": "%scheduler%",
9+ "denoise": "%denoise%",
10+ "model": [
11+ "4",
12+ 0
13+ ],
14+ "positive": [
15+ "6",
16+ 0
17+ ],
18+ "negative": [
19+ "7",
20+ 0
21+ ],
22+ "latent_image": [
23+ "12",
24+ 0
25+ ]
26+ },
27+ "class_type": "KSampler",
28+ "_meta": {
29+ "title": "KSampler"
30+ }
31+ },
32+ "4": {
33+ "inputs": {
34+ "ckpt_name": "%model%"
35+ },
36+ "class_type": "CheckpointLoaderSimple",
37+ "_meta": {
38+ "title": "Load Checkpoint"
39+ }
40+ },
41+ "6": {
42+ "inputs": {
43+ "text": "%prompt%",
44+ "clip": [
45+ "4",
46+ 1
47+ ]
48+ },
49+ "class_type": "CLIPTextEncode",
50+ "_meta": {
51+ "title": "CLIP Text Encode (Prompt)"
52+ }
53+ },
54+ "7": {
55+ "inputs": {
56+ "text": "%negative_prompt%",
57+ "clip": [
58+ "4",
59+ 1
60+ ]
61+ },
62+ "class_type": "CLIPTextEncode",
63+ "_meta": {
64+ "title": "CLIP Text Encode (Negative Prompt)"
65+ }
66+ },
67+ "8": {
68+ "inputs": {
69+ "samples": [
70+ "3",
71+ 0
72+ ],
73+ "vae": [
74+ "4",
75+ 2
76+ ]
77+ },
78+ "class_type": "VAEDecode",
79+ "_meta": {
80+ "title": "VAE Decode"
81+ }
82+ },
83+ "9": {
84+ "inputs": {
85+ "filename_prefix": "SillyTavern",
86+ "images": [
87+ "8",
88+ 0
89+ ]
90+ },
91+ "class_type": "SaveImage",
92+ "_meta": {
93+ "title": "Save Image"
94+ }
95+ },
96+ "10": {
97+ "inputs": {
98+ "image": "%char_avatar%"
99+ },
100+ "class_type": "ETN_LoadImageBase64",
101+ "_meta": {
102+ "title": "Load Image (Base64) [https://github.com/Acly/comfyui-tooling-nodes]"
103+ }
104+ },
105+ "12": {
106+ "inputs": {
107+ "pixels": [
108+ "13",
109+ 0
110+ ],
111+ "vae": [
112+ "4",
113+ 2
114+ ]
115+ },
116+ "class_type": "VAEEncode",
117+ "_meta": {
118+ "title": "VAE Encode"
119+ }
120+ },
121+ "13": {
122+ "inputs": {
123+ "upscale_method": "bicubic",
124+ "width": "%width%",
125+ "height": "%height%",
126+ "crop": "center",
127+ "image": [
128+ "10",
129+ 0
130+ ]
131+ },
132+ "class_type": "ImageScale",
133+ "_meta": {
134+ "title": "Upscale Image"
135+ }
136+ }
137+}
default/content/index.json+4 -0
@@ -136,6 +136,10 @@
136136 "type": "workflow"
137137 },
138138 {
139+ "filename": "Char_Avatar_Comfy_Workflow.json",
140+ "type": "workflow"
141+ },
142+ {
139143 "filename": "presets/kobold/Ace of Spades.json",
140144 "type": "kobold_preset"
141145 },
package-lock.json+3 -3
@@ -3019,9 +3019,9 @@
30193019 }
30203020 },
30213021 "node_modules/cross-spawn": {
30223022 "version": "7.0.35",
30233023 "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.35.tgz",
30243024 "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+UJrags1q15Fudc7G6w4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
30253025 "license": "MIT",
30263026 "dependencies": {
30273027 "path-key": "^3.1.0",
public/css/logprobs.css+8 -0
@@ -72,6 +72,14 @@
7272 opacity: 0.5;
7373}
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+
7583.logprobs_candidate_list {
7684 grid-row-start: 3;
7785 grid-row-end: 4;
public/css/scrollable-button.css+19 -0
@@ -0,0 +1,19 @@
1+.scrollable-buttons-container {
2+ max-height: 50vh; /* Use viewport height instead of fixed pixels */
3+ overflow-y: auto;
4+ -webkit-overflow-scrolling: touch; /* Momentum scrolling on iOS */
5+ margin-top: 1rem; /* m-t-1 is equivalent to margin-top: 1rem; */
6+ flex-shrink: 1;
7+ min-height: 0;
8+ scrollbar-width: thin;
9+ scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
10+}
11+
12+.scrollable-buttons-container::-webkit-scrollbar {
13+ width: 6px;
14+}
15+
16+.scrollable-buttons-container::-webkit-scrollbar-thumb {
17+ background-color: rgba(255, 255, 255, 0.3);
18+ border-radius: 3px;
19+}
public/index.html+8 -2
@@ -3026,6 +3026,7 @@
30263026 <option value="codestral-latest">codestral-latest</option>
30273027 <option value="codestral-mamba-latest">codestral-mamba-latest</option>
30283028 <option value="pixtral-12b-latest">pixtral-12b-latest</option>
3029+ <option value="pixtral-large-latest">pixtral-large-latest</option>
30293030 </optgroup>
30303031 <optgroup label="Sub-versions">
30313032 <option value="open-mistral-nemo-2407">open-mistral-nemo-2407</option>
@@ -3040,10 +3041,12 @@
30403041 <option value="mistral-medium-2312">mistral-medium-2312</option>
30413042 <option value="mistral-large-2402">mistral-large-2402</option>
30423043 <option value="mistral-large-2407">mistral-large-2407</option>
3044+ <option value="mistral-large-2411">mistral-large-2411</option>
30433045 <option value="codestral-2405">codestral-2405</option>
30443046 <option value="codestral-2405-blue">codestral-2405-blue</option>
30453047 <option value="codestral-mamba-2407">codestral-mamba-2407</option>
30463048 <option value="pixtral-12b-2409">pixtral-12b-2409</option>
3049+ <option value="pixtral-large-2411">pixtral-large-2411</option>
30473050 </optgroup>
30483051 <optgroup id="mistralai_other_models" label="Other"></optgroup>
30493052 </select>
@@ -4503,7 +4506,7 @@
45034506 </div>
45044507 </div>
45054508 <div name="AutoCompleteToggle" class="inline-drawer wide100p flexFlowColumn">
45064509 <div class="inline-drawer-toggle inline-drawer-header userSettingsInnerExpandable" title="Options for the various autocompelteautocomplete input boxes.">
45074510 <b><span data-i18n="AutoComplete Settings">AutoComplete Settings</span></b>
45084511 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
45094512 </div>
@@ -6631,8 +6634,11 @@
66316634 </div>
66326635 </div>
66336636 <div class="logprobs_panel_content inline-drawer-content flex-container flexFlowColumn">
6634- <small>
6637+ <small class="flex-container alignItemsCenter justifySpaceBetween flexNoWrap">
66356638 <b data-i18n="Select a token to see alternatives considered by the AI.">Select a token to see alternatives considered by the AI.</b>
6639+ <button id="logprobsReroll" class="menu_button margin0" title="Reroll with the entire prefix" data-i18n="[title]Reroll with the entire prefix">
6640+ <span class="fa-solid fa-redo logprobs_reroll"></span>
6641+ </button>
66366642 </small>
66376643 <hr>
66386644 <div id="logprobs_generation_output"></div>
public/scripts/RossAscends-mods.js+4 -7
@@ -703,16 +703,13 @@ const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
703703 */
704704function autoFitSendTextArea() {
705705 const originalScrollBottom = chatBlock.scrollHeight - (chatBlock.scrollTop + chatBlock.offsetHeight);
706- if (Math.ceil(sendTextArea.scrollHeight + 3) >= Math.floor(sendTextArea.offsetHeight)) {
706+
707- const sendTextAreaMinHeight = '0px';
707+ sendTextArea.style.height = '1px'; // Reset height to 1px to force recalculation of scrollHeight
708- sendTextArea.style.height = sendTextAreaMinHeight;
708+ const newHeight = sendTextArea.scrollHeight;
709- }
710- const newHeight = sendTextArea.scrollHeight + 3;
711709 sendTextArea.style.height = `${newHeight}px`;
712710
713711 if (!isFirefox) {
714712 const newScrollTopchatBlock.scrollTop = Math.round(chatBlock.scrollHeight - (chatBlock.offsetHeight + originalScrollBottom));
715- chatBlock.scrollTop = newScrollTop;
716713 }
717714}
718715export const autoFitSendTextAreaDebounced = debounce(autoFitSendTextArea, debounce_timeout.short);
public/scripts/extensions/caption/settings.html+2 -0
@@ -37,6 +37,8 @@
3737 <select id="caption_multimodal_model" class="flex1 text_pole">
3838 <option data-type="mistral" value="pixtral-12b-latest">pixtral-12b-latest</option>
3939 <option data-type="mistral" value="pixtral-12b-2409">pixtral-12b-2409</option>
40+ <option data-type="mistral" value="pixtral-large-latest">pixtral-large-latest</option>
41+ <option data-type="mistral" value="pixtral-large-2411">pixtral-large-2411</option>
4042 <option data-type="zerooneai" value="yi-vision">yi-vision</option>
4143 <option data-type="openai" value="gpt-4-vision-preview">gpt-4-vision-preview</option>
4244 <option data-type="openai" value="gpt-4-turbo">gpt-4-turbo</option>
public/scripts/extensions/stable-diffusion/comfyWorkflowEditor.html+1 -0
@@ -17,6 +17,7 @@
1717 <li data-placeholder="scheduler" class="sd_comfy_workflow_editor_not_found">"%scheduler%"</li>
1818 <li data-placeholder="steps" class="sd_comfy_workflow_editor_not_found">"%steps%"</li>
1919 <li data-placeholder="scale" class="sd_comfy_workflow_editor_not_found">"%scale%"</li>
20+ <li data-placeholder="denoise" class="sd_comfy_workflow_editor_not_found">"%denoise%"</li>
2021 <li data-placeholder="clip_skip" class="sd_comfy_workflow_editor_not_found">"%clip_skip%"</li>
2122 <li data-placeholder="width" class="sd_comfy_workflow_editor_not_found">"%width%"</li>
2223 <li data-placeholder="height" class="sd_comfy_workflow_editor_not_found">"%height%"</li>
public/scripts/extensions/stable-diffusion/index.js+8 -2
@@ -3269,6 +3269,10 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
32693269
32703270 const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
32713271 workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
3272+
3273+ const denoising_strength = extension_settings.sd.denoising_strength === undefined ? 1.0 : extension_settings.sd.denoising_strength;
3274+ workflow = workflow.replaceAll('"%denoise%"', JSON.stringify(denoising_strength));
3275+
32723276 placeholders.forEach(ph => {
32733277 workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
32743278 });
@@ -3279,7 +3283,8 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
32793283 const response = await fetch(getUserAvatarUrl());
32803284 if (response.ok) {
32813285 const avatarBlob = await response.blob();
32823286 const avatarBase64avatarBase64DataUrl = await getBase64Async(avatarBlob);
3287+ const avatarBase64 = avatarBase64DataUrl.split(',')[1];
32833288 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
32843289 } else {
32853290 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
@@ -3289,7 +3294,8 @@ async function generateComfyImage(prompt, negativePrompt, signal) {
32893294 const response = await fetch(getCharacterAvatarUrl());
32903295 if (response.ok) {
32913296 const avatarBlob = await response.blob();
32923297 const avatarBase64avatarBase64DataUrl = await getBase64Async(avatarBlob);
3298+ const avatarBase64 = avatarBase64DataUrl.split(',')[1];
32933299 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
32943300 } else {
32953301 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
public/scripts/extensions/stable-diffusion/settings.html+1 -1
@@ -319,7 +319,7 @@
319319 <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}}" >
320320 </div>
321321
322322 <div class="alignitemscenter flex-container flexFlowColumn flexGrow flexShrink gap0 flexBasis48p" data-sd-source="auto,vlad,comfy">
323323 <small>
324324 <span data-i18n="Denoising strength">Denoising strength</span>
325325 </small>
public/scripts/extensions/vectors/index.js+15 -5
@@ -23,7 +23,7 @@ import {
2323import { collapseNewlines, registerDebugFunction } from '../../power-user.js';
2424import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
2525import { getDataBankAttachments, getDataBankAttachmentsForSource, getFileAttachment } from '../../chats.js';
2626import { debounce, getStringHash as calculateHash, waitUntilCondition, onlyUnique, splitRecursive, trimToStartSentence, trimToEndSentence, escapeHtml } from '../../utils.js';
2727import { debounce_timeout } from '../../constants.js';
2828import { getSortedEntries } from '../../world-info.js';
2929import { textgen_types, textgenerationwebui_settings } from '../../textgen-settings.js';
@@ -44,6 +44,9 @@ const MODULE_NAME = 'vectors';
4444export const EXTENSION_PROMPT_TAG = '3_vectors';
4545export const EXTENSION_PROMPT_TAG_DB = '4_vectors_data_bank';
4646
47+// Force solo chunks for sources that don't support batching.
48+const getBatchSize = () => ['transformers', 'palm', 'ollama'].includes(settings.source) ? 1 : 5;
49+
4750const settings = {
4851 // For both
4952 source: 'transformers',
@@ -125,7 +128,7 @@ async function onVectorizeAllClick() {
125128 // upon request of a full vectorise
126129 cachedSummaries.clear();
127130
128131 const batchSize = 5getBatchSize();
129132 const elapsedLog = [];
130133 let finished = false;
131134 $('#vectorize_progress').show();
@@ -560,7 +563,9 @@ async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overla
560563 fileText = translatedText;
561564 }
562565
563- const toast = toastr.info('Vectorization may take some time, please wait...', `Ingesting file ${fileName}`);
566+ const batchSize = getBatchSize();
567+ const toastBody = $('<span>').text('This may take a while. Please wait...');
568+ const toast = toastr.info(toastBody, `Ingesting file ${escapeHtml(fileName)}`, { closeButton: false, escapeHtml: false, timeOut: 0, extendedTimeOut: 0 });
564569 const overlapSize = Math.round(chunkSize * overlapPercent / 100);
565570 const delimiters = getChunkDelimiters();
566571 // 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
569574 console.debug(`Vectors: Split file ${fileName} into ${chunks.length} chunks with ${overlapPercent}% overlap`, chunks);
570575
571576 const items = chunks.map((chunk, index) => ({ hash: getStringHash(chunk), text: chunk, index: index }));
572- await insertVectorItems(collectionId, items);
577+
578+ for (let i = 0; i < items.length; i += batchSize) {
579+ toastBody.text(`${i}/${items.length} (${Math.round((i / items.length) * 100)}%) chunks processed`);
580+ const chunkedBatch = items.slice(i, i + batchSize);
581+ await insertVectorItems(collectionId, chunkedBatch);
582+ }
573583
574584 toastr.clear(toast);
575585 console.log(`Vectors: Inserted ${chunks.length} vector items for file ${fileName} into ${collectionId}`);
@@ -1050,7 +1060,7 @@ async function onViewStatsClick() {
10501060 toastr.info(`Total hashes: <b>${totalHashes}</b><br>
10511061 Unique hashes: <b>${uniqueHashes}</b><br><br>
10521062 I'll mark collected messages with a green circle.`,
10531063 `Stats for chat ${escapeHtml(chatId)}`,
10541064 { timeOut: 10000, escapeHtml: false },
10551065 );
10561066
public/scripts/logprobs.js+130 -115
@@ -1,6 +1,5 @@
11import {
22 animation_duration,
3- callPopup,
43 chat,
54 cleanUpMessage,
65 event_types,
@@ -13,9 +12,12 @@ import {
1312import { debounce, delay, getStringHash } from './utils.js';
1413import { decodeTextTokens, getTokenizerBestMatch } from './tokenizers.js';
1514import { power_user } from './power-user.js';
15+import { callGenericPopup, POPUP_TYPE } from './popup.js';
16+import { t } from './i18n.js';
1617
1718const TINTS = 4;
1819const MAX_MESSAGE_LOGPROBS = 100;
20+const REROLL_BUTTON = $('#logprobsReroll');
1921
2022/**
2123 * Tuple of a candidate token and its logarithm of probability of being chosen
@@ -23,6 +25,10 @@ const MAX_MESSAGE_LOGPROBS = 100;
2325 */
2426
2527/**
28+ * @typedef {(Node|JQuery<Text>|JQuery<HTMLElement>)[]} NodeArray - Array of DOM nodes
29+ */
30+
31+/**
2632 * Logprob data for a single message
2733 * @typedef {Object} MessageLogprobData
2834 * @property {number} created - timestamp of when the message was generated
@@ -43,17 +49,26 @@ const MAX_MESSAGE_LOGPROBS = 100;
4349 * @property {Candidate[]} topLogprobs - Array of top candidate tokens
4450 */
4551
46-let state = {
52+/**
47- /** @type {TokenLogprobs | null} */
53+ * State object for Token Probabilities
54+ * @typedef {Object} LogprobsState
55+ * @property {?TokenLogprobs} selectedTokenLogprobs Log probabilities for
56+ * currently-selected token.
57+ * @property {Map<number, MessageLogprobData>} messageLogprobs Log probabilities for
58+ * each message, keyed by message hash.
59+ */
60+
61+/**
62+ * @type {LogprobsState} state
63+ */
64+const state = {
4865 selectedTokenLogprobs: null,
49- /** @type {Map<number, MessageLogprobData>} */
5066 messageLogprobs: new Map(),
5167};
5268
5369/**
5470 * renderAlternativeTokensView rendersRenders the Token Probabilities UI and all subviews with the active message's
5571 * subviews with the active message's logprobs data. If the message has no token logprobs, a message is displayed.
56- * logprobs, a zero-state is rendered.
5772 */
5873function renderAlternativeTokensView() {
5974 const view = $('#logprobs_generation_output');
@@ -68,13 +83,14 @@ function renderAlternativeTokensView() {
6883 const usingSmoothStreaming = isStreamingEnabled() && power_user.smooth_streaming;
6984 if (!messageLogprobs?.length || usingSmoothStreaming) {
7085 const emptyState = $('<div></div>');
7186 const noTokensMsg = usingSmoothStreaming!power_user.request_token_probabilities
72- ? 'Token probabilities are not available when using Smooth Streaming.'
87+ ? '<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.';
88+ : usingSmoothStreaming
74- const msg = power_user.request_token_probabilities
89+ ? t`Token probabilities are not available when using Smooth Streaming.`
75- ? noTokensMsg
90+ : is_send_press
76- : '<span>Enable <b>Request token probabilities</b> in the User Settings menu to use this feature.</span>';
91+ ? t`Generation in progress...`
77- emptyState.html(msg);
92+ : t`No token probabilities available for the current message.`;
93+ emptyState.html(noTokensMsg);
7894 emptyState.addClass('logprobs_empty_state');
7995 view.append(emptyState);
8096 return;
@@ -82,16 +98,34 @@ function renderAlternativeTokensView() {
8298
8399 const prefix = continueFrom || '';
84100 const tokenSpans = [];
101+ REROLL_BUTTON.toggle(!!prefix);
85102
86103 if (prefix) {
87- const prefixSpan = $('<span></span>');
104+ REROLL_BUTTON.off('click').on('click', () => onPrefixClicked(prefix.length));
88- prefixSpan.text(prefix);
105+
89- prefixSpan.html(prefixSpan.html().replace(/\n/g, '<br>'));
106+ let cumulativeOffset = 0;
90- prefixSpan.addClass('logprobs_output_prefix');
107+ const words = prefix.split(/\s+/);
91- prefixSpan.attr('title', 'Select to reroll the last \'Continue\' generation.\nHold the CTRL key when clicking to reroll from before that word.');
108+ const delimiters = prefix.match(/\s+/g) || []; // Capture the actual delimiters
92- prefixSpan.click(onPrefixClicked);
109+
93- addKeyboardProps(prefixSpan);
110+ words.forEach((word, i) => {
94- tokenSpans.push(...withVirtualWhitespace(prefix, prefixSpan));
111+ const span = $('<span></span>');
112+ span.text(`${word} `);
113+
114+ span.addClass('logprobs_output_prefix');
115+ span.attr('title', t`Reroll from this point`);
116+
117+ let offset = cumulativeOffset;
118+ span.on('click', () => onPrefixClicked(offset));
119+ addKeyboardProps(span);
120+
121+ tokenSpans.push(span);
122+ tokenSpans.push(delimiters[i]?.includes('\n')
123+ ? document.createElement('br')
124+ : document.createTextNode(delimiters[i] || ' '),
125+ );
126+
127+ cumulativeOffset += word.length + (delimiters[i]?.length || 0);
128+ });
95129 }
96130
97131 messageLogprobs.forEach((tokenData, i) => {
@@ -101,7 +135,7 @@ function renderAlternativeTokensView() {
101135 span.text(text);
102136 span.addClass('logprobs_output_token');
103137 span.addClass('logprobs_tint_' + (i % TINTS));
104138 span.clickon('click', () => onSelectedTokenChanged(tokenData, span));
105139 addKeyboardProps(span);
106140 tokenSpans.push(...withVirtualWhitespace(token, span));
107141 });
@@ -129,6 +163,10 @@ function addKeyboardProps(element) {
129163/**
130164 * renderTopLogprobs renders the top logprobs subview with the currently
131165 * 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
132170 */
133171function renderTopLogprobs() {
134172 $('#logprobs_top_logprobs_hint').hide();
@@ -150,8 +188,7 @@ function renderTopLogprobs() {
150188 const probability = Math.exp(log);
151189 sum += probability;
152190 return [text, probability, log];
153191 } else {
154- else {
155192 return [text, log, null];
156193 }
157194 });
@@ -167,15 +204,15 @@ function renderTopLogprobs() {
167204 container.addClass('selected');
168205 }
169206
170207 const tokenText = $('<span></span>').text(`${toVisibleWhitespace(token.toString())}`);
171208 const percentText = $('<span></span>').text(`${(+probability * 100).toFixed(2)}%`);
172209 container.append(tokenText, percentText);
173210 if (log) {
174211 container.attr('title', `logarithm: ${log}`);
175212 }
176213 addKeyboardProps(container);
177214 if (token !== '<others>') {
178215 container.clickon('click', () => onAlternativeClicked(state.selectedTokenLogprobs, token.toString()));
179216 } else {
180217 container.prop('disabled', true);
181218 }
@@ -192,11 +229,10 @@ function renderTopLogprobs() {
192229}
193230
194231/**
195- * onSelectedTokenChanged is called when the user clicks on a token in the
232+ * 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 the
233+ * 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.
198234 * @param {TokenLogprobs} logprobs - logprob data for the selected token
199235 * @param {ElementNode|JQuery} span - target span node that was clicked
200236 */
201237function onSelectedTokenChanged(logprobs, span) {
202238 $('.logprobs_output_token.selected').removeClass('selected');
@@ -223,7 +259,10 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
223259 }
224260
225261 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);
227266 }
228267
229268 const { messageLogprobs, continueFrom } = getActiveMessageLogprobData();
@@ -234,79 +273,29 @@ function onAlternativeClicked(tokenLogprobs, alternative) {
234273
235274 const prefix = continueFrom || '';
236275 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 _);
243277}
244278
245279/**
246- * getTextBeforeClickedWord retrieves the portion of text within a span
280+ * User clicks on the reroll button in the token output view, or on a word in the
247281 * that appearsprefix. beforeRetrieve the word clickedprefix byfor the user.current Usingmessage theand xtruncate andit yat the
248- * coordinates from a PointerEvent, this function identifies the exact
282+ * 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.
253284 *
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- */
258-function 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.
287286 *
288287 * If the@param user{number} holdsoffset the- Ctrlindex keyof whilethe clicking,token onlyin the portionprefix ofto textreroll from
289- * before the clicked word is retained as the prefix for rerolling
288+ * @returns {void}
289+ * @param offset
290290 */
291291function onPrefixClicked(offset = undefined) {
292292 if (!checkGenerateReady()) {
293293 return;
294294 }
295295
296296 const { continueFrom } = getActiveMessageLogprobData() || {};
297- const messageId = chat.length - 1;
297+ const prefix = continueFrom ? continueFrom.substring(0, offset) : '';
298-
298+ 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 _);
310299}
311300
312301function checkGenerateReady() {
@@ -317,6 +306,22 @@ function checkGenerateReady() {
317306 return true;
318307}
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+ */
315+function 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
321326/**
322327 * onToggleLogprobsPanel is called when the user performs an action that toggles
@@ -356,15 +361,14 @@ function onToggleLogprobsPanel() {
356361}
357362
358363/**
359364 * createSwipe appendsAppends a new swipe to the target chat message with the given text.
360- * text.
361365 * @param {number} messageId - target chat message ID
362366 * @param {string} prompt - initial prompt text which will be continued
363367 */
364368function createSwipe(messageId, prompt) {
365369 // need to call `cleanUpMessage` on our new prompt, because we were working
366370 // with raw model output and our new prompt is missing trimming/macro replacements
367371 const cleanedPrompt = cleanUpMessage(prompt, false, false, true);
368372
369373 const msg = chat[messageId];
370374 const newSwipeInfo = {
@@ -399,10 +403,11 @@ function toVisibleWhitespace(input) {
399403 * after the span node if its token begins or ends with whitespace in order to
400404 * allow text to wrap despite whitespace characters being replaced with a dot.
401405 * @param {string} text - token text being evaluated for whitespace
402406 * @param {ElementNode|JQuery} span - target span node to be wrapped
403407 * @returns {Element[]NodeArray} - array of nodes to be appended to the DOMparent element
404408 */
405409function withVirtualWhitespace(text, span) {
410+ /** @type {NodeArray} */
406411 const result = [span];
407412 if (text.match(/^\s/)) {
408413 result.unshift(document.createTextNode('\u200b'));
@@ -430,12 +435,16 @@ function withVirtualWhitespace(text, span) {
430435}
431436
432437/**
433- * saveLogprobsForActiveMessage receives an array of TokenLogprobs objects
438+ * 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 it
439+ *
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
436447 *
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.**
439448 * @param {TokenLogprobs[]} logprobs - array of logprobs data for each token
440449 * @param {string | null} continueFrom - for 'continue' generations, the prompt
441450 */
@@ -445,7 +454,10 @@ export function saveLogprobsForActiveMessage(logprobs, continueFrom) {
445454 return;
446455 }
447456
448- convertTokenIdLogprobsToText(logprobs);
457+ // NovelAI only returns token IDs in logprobs data; convert to text tokens in-place
458+ if (getGeneratingApi() === 'novel') {
459+ convertTokenIdLogprobsToText(logprobs);
460+ }
449461
450462 const msgId = chat.length - 1;
451463 /** @type {MessageLogprobData} */
@@ -491,17 +503,18 @@ function getActiveMessageLogprobData() {
491503 return state.messageLogprobs.get(hash) || null;
492504}
493505
506+
494507/**
495508 * convertLogprobTokenIdsToText mutatesreplaces thetoken givenIDs in logprobs data's topLogprobswith text tokens,
496509 * fieldfor keyedAPIs bythat return token textIDs instead of token ID. Thistext istokens, onlyto necessarywit: forNovelAI.
497- * APIs which only return token IDs in their logprobs data; for others this
510+ *
498- * function is a no-op.
499511 * @param {TokenLogprobs[]} input - logprobs data with numeric token IDs
500512 */
501513function convertTokenIdLogprobsToText(input) {
502514 const api = getGeneratingApi();
503515 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');
505518 }
506519
507520 const tokenizerId = getTokenizerBestMatch(api);
@@ -512,7 +525,8 @@ function convertTokenIdLogprobsToText(input) {
512525 )));
513526
514527 // Submit token IDs to tokenizer to get token text, then build ID->text map
515- const { chunks } = decodeTextTokens(tokenizerId, tokenIds);
528+ // noinspection JSCheckFunctionSignatures - mutates input in-place
529+ const { chunks } = decodeTextTokens(tokenizerId, tokenIds.map(parseInt));
516530 const tokenIdText = new Map(tokenIds.map((id, i) => [id, chunks[i]]));
517531
518532 // Fixup logprobs data with token text
@@ -525,9 +539,10 @@ function convertTokenIdLogprobsToText(input) {
525539}
526540
527541export function initLogprobs() {
542+ REROLL_BUTTON.hide();
528543 const debouncedRender = debounce(renderAlternativeTokensView);
529544 $('#logprobsViewerClose').clickon('click', onToggleLogprobsPanel);
530545 $('#option_toggle_logprobs').clickon('click', onToggleLogprobsPanel);
531546 eventSource.on(event_types.CHAT_CHANGED, debouncedRender);
532547 eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, debouncedRender);
533548 eventSource.on(event_types.IMPERSONATE_READY, debouncedRender);
public/scripts/openai.js+3 -1
@@ -4165,7 +4165,7 @@ async function onModelChange() {
41654165 $('#openai_max_context').attr('max', unlocked_max);
41664166 } else if (oai_settings.mistralai_model.includes('codestral-mamba')) {
41674167 $('#openai_max_context').attr('max', max_256k);
41684168 } else if (['mistral-large-2407', 'mistral-large-2411', 'mistral-large-latest'].includes(oai_settings.mistralai_model)) {
41694169 $('#openai_max_context').attr('max', max_128k);
41704170 } else if (oai_settings.mistralai_model.includes('mistral-nemo')) {
41714171 $('#openai_max_context').attr('max', max_128k);
@@ -4764,6 +4764,8 @@ export function isImageInliningSupported() {
47644764 'pixtral-12b-latest',
47654765 'pixtral-12b',
47664766 'pixtral-12b-2409',
4767+ 'pixtral-large-latest',
4768+ 'pixtral-large-2411',
47674769 ];
47684770
47694771 switch (oai_settings.chat_completion_source) {
public/scripts/slash-commands.js+12 -2
@@ -2083,7 +2083,10 @@ async function buttonsCallback(args, text) {
20832083 let popup;
20842084
20852085 const buttonContainer = document.createElement('div');
20862086 buttonContainer.classList.add('flex-container', 'flexFlowColumn', 'wide100p', 'm-t-1');
2087+
2088+ const scrollableContainer = document.createElement('div');
2089+ scrollableContainer.classList.add('scrollable-buttons-container');
20872090
20882091 for (const [result, button] of resultToButtonMap) {
20892092 const buttonElement = document.createElement('div');
@@ -2096,9 +2099,16 @@ async function buttonsCallback(args, text) {
20962099 buttonContainer.appendChild(buttonElement);
20972100 }
20982101
2102+ scrollableContainer.appendChild(buttonContainer);
2103+
20992104 const popupContainer = document.createElement('div');
21002105 popupContainer.innerHTML = safeValue;
21012106 popupContainer.appendChild(buttonContainerscrollableContainer);
2107+
2108+ // Ensure the popup uses flex layout
2109+ popupContainer.style.display = 'flex';
2110+ popupContainer.style.flexDirection = 'column';
2111+ popupContainer.style.maxHeight = '80vh'; // Limit the overall height of the popup
21022112
21032113 popup = new Popup(popupContainer, POPUP_TYPE.TEXT, '', { okButton: 'Cancel', allowVerticalScrolling: true });
21042114 popup.show()
public/scripts/textgen-models.js+24 -11
@@ -23,29 +23,42 @@ export let openRouterModels = [];
2323const OPENROUTER_PROVIDERS = [
2424 'OpenAI',
2525 'Anthropic',
26- 'HuggingFace',
2726 'Google',
2827 'MancerGoogle AI Studio',
2928 'Mancer 2Groq',
29+ 'SambaNova',
30+ 'Cohere',
31+ 'Mistral',
3032 'Together',
33+ 'Together 2',
34+ 'Fireworks',
3135 'DeepInfra',
36+ 'Lepton',
37+ 'Novita',
38+ 'Avian',
39+ 'Lambda',
3240 'Azure',
3341 'Modal',
3442 'AnyScale',
3543 'Replicate',
3644 'Perplexity',
3745 'Recursal',
38- 'Fireworks',
39- 'Mistral',
40- 'Groq',
41- 'Cohere',
42- 'Lepton',
4346 'OctoAI',
44- 'Novita',
45- 'Lynn',
46- 'Lynn 2',
4747 'DeepSeek',
4848 'Infermatic',
49+ 'AI21',
50+ 'Featherless',
51+ 'Inflection',
52+ 'xAI',
53+ '01.AI',
54+ 'HuggingFace',
55+ 'Mancer',
56+ 'Mancer 2',
57+ 'Hyperbolic',
58+ 'Hyperbolic 2',
59+ 'Lynn 2',
60+ 'Lynn',
61+ 'Reflection',
4962];
5063
5164export async function loadOllamaModels(data) {
public/style.css+1 -0
@@ -9,6 +9,7 @@
99@import url(css/logprobs.css);
1010@import url(css/accounts.css);
1111@import url(css/tags.css);
12+@import url(css/scrollable-button.css);
1213
1314:root {
1415 --doc-height: 100%;
src/endpoints/stable-diffusion.js+33 -17
@@ -7,7 +7,7 @@ import sanitize from 'sanitize-filename';
77import { sync as writeFileAtomicSync } from 'write-file-atomic';
88import FormData from 'form-data';
99
1010import { delay, getBasicAuthHeader, delaytryParse } from '../util.js';
1111import { jsonParser } from '../express-common.js';
1212import { readSecret, SECRET_KEYS } from './secrets.js';
1313
@@ -19,7 +19,7 @@ import { readSecret, SECRET_KEYS } from './secrets.js';
1919function getComfyWorkflows(directories) {
2020 return fs
2121 .readdirSync(directories.comfyWorkflows)
2222 .filter(file => file[0] !== '.' && file.toLowerCase().endsWith('.json'))
2323 .sort(Intl.Collator().compare);
2424}
2525
@@ -67,8 +67,7 @@ router.post('/upscalers', jsonParser, async (request, response) => {
6767
6868 /** @type {any} */
6969 const data = await result.json();
7070 const names =return data.map(x => x.name);
71- return names;
7271 }
7372
7473 async function getLatentUpscalers() {
@@ -88,8 +87,7 @@ router.post('/upscalers', jsonParser, async (request, response) => {
8887
8988 /** @type {any} */
9089 const data = await result.json();
9190 const names =return data.map(x => x.name);
92- return names;
9391 }
9492
9593 const [upscalers, latentUpscalers] = await Promise.all([getUpscalerModels(), getLatentUpscalers()]);
@@ -241,8 +239,7 @@ router.post('/set-model', jsonParser, async (request, response) => {
241239 'Authorization': getBasicAuthHeader(request.body.auth),
242240 },
243241 });
244242 const data =return await result.json();
245- return data;
246243 }
247244
248245 const url = new URL(request.body.url);
@@ -274,7 +271,7 @@ router.post('/set-model', jsonParser, async (request, response) => {
274271
275272 const progress = progressState['progress'];
276273 const jobCount = progressState['state']['job_count'];
277274 if (progress === 0.0 && jobCount === 0) {
278275 break;
279276 }
280277
@@ -412,8 +409,19 @@ comfy.post('/models', jsonParser, async (request, response) => {
412409 }
413410 /** @type {any} */
414411 const data = await result.json();
415- return response.send(data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })));
412+
416- } catch (error) {
413+ const ckpts = data.CheckpointLoaderSimple.input.required.ckpt_name[0].map(it => ({ value: it, text: it })) || [];
414+ const unets = data.UNETLoader.input.required.unet_name[0].map(it => ({ value: it, text: `UNet: ${it}` })) || [];
415+
416+ // load list of GGUF unets from diffusion_models if the loader node is available
417+ const ggufs = data.UnetLoaderGGUF?.input.required.unet_name[0].map(it => ({ value: it, text: `GGUF: ${it}` })) || [];
418+ const models = [...ckpts, ...unets, ...ggufs];
419+
420+ // make the display names of the models somewhat presentable
421+ models.forEach(it => it.text = it.text.replace(/\.[^.]*$/, '').replace(/_/g, ' '));
422+
423+ return response.send(models);
424+ } catch (error) {
417425 console.log(error);
418426 return response.sendStatus(500);
419427 }
@@ -527,7 +535,8 @@ comfy.post('/generate', jsonParser, async (request, response) => {
527535 body: request.body.prompt,
528536 });
529537 if (!promptResult.ok) {
530- throw new Error('ComfyUI returned an error.');
538+ const text = await promptResult.text();
539+ throw new Error('ComfyUI returned an error.', { cause: tryParse(text) });
531540 }
532541
533542 /** @type {any} */
@@ -550,7 +559,13 @@ comfy.post('/generate', jsonParser, async (request, response) => {
550559 await delay(100);
551560 }
552561 if (item.status.status_str === 'error') {
553- throw new Error('ComfyUI generation did not succeed.');
562+ // Report node tracebacks if available
563+ const errorMessages = item.status?.messages
564+ ?.filter(it => it[0] === 'execution_error')
565+ .map(it => it[1])
566+ .map(it => `${it.node_type} [${it.node_id}] ${it.exception_type}: ${it.exception_message}`)
567+ .join('\n') || '';
568+ throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim());
554569 }
555570 const imgInfo = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0];
556571 const imgUrl = new URL(request.body.url);
@@ -560,11 +575,12 @@ comfy.post('/generate', jsonParser, async (request, response) => {
560575 if (!imgResponse.ok) {
561576 throw new Error('ComfyUI returned an error.');
562577 }
563578 const imgBuffer = await imgResponse.bufferarrayBuffer();
564579 return response.send(Buffer.from(imgBuffer).toString('base64'));
565580 } catch (error) {
566581 console.log('ComfyUI error:', error);
567582 return response.sendStatusstatus(500).send(error.message);
583+ return response;
568584 }
569585});
570586