Support multiple images in single API response (#4734) * Support multiple images in single API response * Fix media wrapper clashing with swipe counter * Return an empty array, add additional filtering * Add index to inline image filenames in processImageAttachment

c04be57f84b041a4e6cd23e2d22cf4d7a63f9d27

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

Signed
4 files changed, +43 -36Showing whitespace changes
public/script.js+34 -28
@@ -3122,8 +3122,8 @@ class StreamingProcessor {
3122 this.reasoningHandler = new ReasoningHandler(timeStarted);3122 this.reasoningHandler = new ReasoningHandler(timeStarted);
3123 /** @type {PromptReasoning} */3123 /** @type {PromptReasoning} */
3124 this.promptReasoning = promptReasoning;3124 this.promptReasoning = promptReasoning;
3125 /** @type {string} */3125 /** @type {string[]} */
3126 this.image = '';3126 this.images = [];
3127 }3127 }
31283128
3129 /**3129 /**
@@ -3309,8 +3309,8 @@ class StreamingProcessor {
3309 chat[messageId].swipe_info.push(...swipeInfoArray);3309 chat[messageId].swipe_info.push(...swipeInfoArray);
3310 }3310 }
33113311
3312 if (this.image) {3312 if (Array.isArray(this.images) && this.images.length > 0) {
3313 await processImageAttachment(chat[messageId], { imageUrl: this.image });3313 await processImageAttachment(chat[messageId], { imageUrls: this.images });
3314 appendMediaToMessage(chat[messageId], $(this.messageDom));3314 appendMediaToMessage(chat[messageId], $(this.messageDom));
3315 }3315 }
33163316
@@ -3408,7 +3408,7 @@ class StreamingProcessor {
3408 }3408 }
3409 // Get the updated reasoning string into the handler3409 // Get the updated reasoning string into the handler
3410 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);3410 this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning);
3411 this.image = state?.image ?? '';3411 this.images = state?.images ?? [];
3412 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);3412 await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text);
3413 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));3413 await sw.tick(async () => await this.onProgressStreaming(this.messageId, this.continueMessage + text));
3414 }3414 }
@@ -4970,7 +4970,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
4970 let getMessage = extractMessageFromData(data);4970 let getMessage = extractMessageFromData(data);
4971 let title = extractTitleFromData(data);4971 let title = extractTitleFromData(data);
4972 let reasoning = extractReasoningFromData(data);4972 let reasoning = extractReasoningFromData(data);
4973 let imageUrl = extractImageFromData(data);4973 let imageUrls = extractImagesFromData(data);
4974 kobold_horde_model = title;4974 kobold_horde_model = title;
49754975
4976 const swipes = extractMultiSwipes(data, type);4976 const swipes = extractMultiSwipes(data, type);
@@ -5014,10 +5014,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
5014 else {5014 else {
5015 // Without streaming we'll be having a full message on continuation. Treat it as a last chunk.5015 // Without streaming we'll be having a full message on continuation. Treat it as a last chunk.
5016 if (originalType !== 'continue') {5016 if (originalType !== 'continue') {
5017 ({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrl }));5017 ({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrls }));
5018 }5018 }
5019 else {5019 else {
5020 ({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrl }));5020 ({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrls }));
5021 }5021 }
50225022
5023 // This relies on `saveReply` having been called to add the message to the chat, so it must be last.5023 // This relies on `saveReply` having been called to add the message to the chat, so it must be last.
@@ -5641,23 +5641,23 @@ function extractTitleFromData(data) {
5641 * @param {object} [options] Extraction options5641 * @param {object} [options] Extraction options
5642 * @param {string} [options.mainApi] Main API to use5642 * @param {string} [options.mainApi] Main API to use
5643 * @param {string} [options.chatCompletionSource] Chat completion source5643 * @param {string} [options.chatCompletionSource] Chat completion source
5644 * @returns {string} Extracted image5644 * @returns {string[]} Extracted images or empty array
5645 */5645 */
5646function extractImageFromData(data, { mainApi = null, chatCompletionSource = null } = {}) {5646function extractImagesFromData(data, { mainApi = null, chatCompletionSource = null } = {}) {
5647 switch (mainApi ?? main_api) {5647 switch (mainApi ?? main_api) {
5648 case 'openai': {5648 case 'openai': {
5649 switch (chatCompletionSource ?? oai_settings.chat_completion_source) {5649 switch (chatCompletionSource ?? oai_settings.chat_completion_source) {
5650 case chat_completion_sources.VERTEXAI:5650 case chat_completion_sources.VERTEXAI:
5651 case chat_completion_sources.MAKERSUITE: {5651 case chat_completion_sources.MAKERSUITE: {
5652 const inlineData = data?.responseContent?.parts?.find(x => x.inlineData)?.inlineData;5652 const inlineData = data?.responseContent?.parts?.filter(x => x.inlineData)?.map(x => x.inlineData);
5653 if (inlineData) {5653 if (Array.isArray(inlineData) && inlineData.length > 0) {
5654 return `data:${inlineData.mimeType};base64,${inlineData.data}`;5654 return inlineData.map(x => `data:${x.mimeType};base64,${x.data}`).filter(isDataURL);
5655 }5655 }
5656 } break;5656 } break;
5657 case chat_completion_sources.OPENROUTER: {5657 case chat_completion_sources.OPENROUTER: {
5658 const imageUrl = data?.choices[0]?.message?.images?.find(x => x.type === 'image_url')?.image_url?.url;5658 const imageUrl = data?.choices[0]?.message?.images?.filter(x => x.type === 'image_url')?.map(x => x?.image_url?.url);
5659 if (isDataURL(imageUrl)) {5659 if (Array.isArray(imageUrl) && imageUrl.length > 0) {
5660 return imageUrl;5660 return imageUrl.filter(isDataURL);
5661 }5661 }
5662 // TODO: Handle remote URLs5662 // TODO: Handle remote URLs
5663 }5663 }
@@ -5665,7 +5665,7 @@ function extractImageFromData(data, { mainApi = null, chatCompletionSource = nul
5665 } break;5665 } break;
5666 }5666 }
56675667
5668 return undefined;5668 return [];
5669}5669}
56705670
5671/**5671/**
@@ -6012,23 +6012,29 @@ export function cleanUpMessage({ getMessage, isImpersonate, isContinue, displayI
6012 * Adds an image to the message.6012 * Adds an image to the message.
6013 * @param {object} message Message object6013 * @param {object} message Message object
6014 * @param {object} sources Image sources6014 * @param {object} sources Image sources
6015 * @param {string} [sources.imageUrl] Image URL6015 * @param {string[]} [sources.imageUrls] Image URLs
6016 *6016 *
6017 * @returns {Promise<void>}6017 * @returns {Promise<void>}
6018 */6018 */
6019async function processImageAttachment(message, { imageUrl }) {6019async function processImageAttachment(message, { imageUrls }) {
6020 if (!imageUrl) {6020 if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
6021 return;6021 return;
6022 }6022 }
60236023
6024 for (const [index, imageUrl] of imageUrls.entries()) {
6025 if (!imageUrl) {
6026 continue;
6027 }
6028
6024 let url = imageUrl;6029 let url = imageUrl;
6025 if (isDataURL(url)) {6030 if (isDataURL(url)) {
6026 const fileName = `inline_image_${Date.now().toString()}`;6031 const fileName = `inline_image_${Date.now().toString()}_${index}`;
6027 const [mime, base64] = /^data:(.*?);base64,(.*)$/.exec(imageUrl).slice(1);6032 const [mime, base64] = /^data:(.*?);base64,(.*)$/.exec(imageUrl).slice(1);
6028 url = await saveBase64AsFile(base64, message.name, fileName, mime.split('/')[1]);6033 url = await saveBase64AsFile(base64, message.name, fileName, mime.split('/')[1]);
6029 }6034 }
6030 saveImageToMessage({ image: url, inline: true }, message);6035 saveImageToMessage({ image: url, inline: true }, message);
6031 }6036 }
6037}
60326038
6033/**6039/**
6034 * Saves a resulting message to the chat.6040 * Saves a resulting message to the chat.
@@ -6042,17 +6048,17 @@ async function processImageAttachment(message, { imageUrl }) {
6042 * @property {string} [title] Message tooltip6048 * @property {string} [title] Message tooltip
6043 * @property {string[]} [swipes] Extra swipes6049 * @property {string[]} [swipes] Extra swipes
6044 * @property {string} [reasoning] Message reasoning6050 * @property {string} [reasoning] Message reasoning
6045 * @property {string} [imageUrl] Link to an image6051 * @property {string[]} [imageUrls] Links to images
6046 *6052 *
6047 * @typedef {object} SaveReplyResult6053 * @typedef {object} SaveReplyResult
6048 * @property {string} type Type of generation6054 * @property {string} type Type of generation
6049 * @property {string} getMessage Generated message6055 * @property {string} getMessage Generated message
6050 */6056 */
6051export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrl = '' }) {6057export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [] }) {
6052 // Backward compatibility6058 // Backward compatibility
6053 if (arguments.length > 1 && typeof arguments[0] !== 'object') {6059 if (arguments.length > 1 && typeof arguments[0] !== 'object') {
6054 console.trace('saveReply called with positional arguments. Please use an object instead.');6060 console.trace('saveReply called with positional arguments. Please use an object instead.');
6055 [type, getMessage, fromStreaming, title, swipes, reasoning, imageUrl] = arguments;6061 [type, getMessage, fromStreaming, title, swipes, reasoning, imageUrls] = arguments;
6056 }6062 }
60576063
6058 if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (chat[chat.length - 1]['swipe_id'] === undefined ||6064 if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (chat[chat.length - 1]['swipe_id'] === undefined ||
@@ -6088,7 +6094,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
6088 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();6094 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
6089 chat[chat.length - 1]['extra']['reasoning'] = reasoning;6095 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
6090 chat[chat.length - 1]['extra']['reasoning_duration'] = null;6096 chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6091 await processImageAttachment(chat[chat.length - 1], { imageUrl });6097 await processImageAttachment(chat[chat.length - 1], { imageUrls });
6092 if (power_user.message_token_count_enabled) {6098 if (power_user.message_token_count_enabled) {
6093 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];6099 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
6094 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);6100 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
@@ -6112,7 +6118,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
6112 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();6118 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
6113 chat[chat.length - 1]['extra']['reasoning'] = reasoning;6119 chat[chat.length - 1]['extra']['reasoning'] = reasoning;
6114 chat[chat.length - 1]['extra']['reasoning_duration'] = null;6120 chat[chat.length - 1]['extra']['reasoning_duration'] = null;
6115 await processImageAttachment(chat[chat.length - 1], { imageUrl });6121 await processImageAttachment(chat[chat.length - 1], { imageUrls });
6116 if (power_user.message_token_count_enabled) {6122 if (power_user.message_token_count_enabled) {
6117 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];6123 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
6118 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);6124 chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0);
@@ -6132,7 +6138,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
6132 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();6138 chat[chat.length - 1]['extra']['api'] = getGeneratingApi();
6133 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();6139 chat[chat.length - 1]['extra']['model'] = getGeneratingModel();
6134 chat[chat.length - 1]['extra']['reasoning'] += reasoning;6140 chat[chat.length - 1]['extra']['reasoning'] += reasoning;
6135 await processImageAttachment(chat[chat.length - 1], { imageUrl });6141 await processImageAttachment(chat[chat.length - 1], { imageUrls });
6136 // We don't know if the reasoning duration extended, so we don't update it here on purpose.6142 // We don't know if the reasoning duration extended, so we don't update it here on purpose.
6137 if (power_user.message_token_count_enabled) {6143 if (power_user.message_token_count_enabled) {
6138 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];6144 const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes'];
@@ -6178,7 +6184,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title
6178 chat[chat.length - 1]['extra']['gen_id'] = group_generation_id;6184 chat[chat.length - 1]['extra']['gen_id'] = group_generation_id;
6179 }6185 }
61806186
6181 await processImageAttachment(chat[chat.length - 1], { imageUrl });6187 await processImageAttachment(chat[chat.length - 1], { imageUrls });
6182 const chat_id = (chat.length - 1);6188 const chat_id = (chat.length - 1);
61836189
6184 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);6190 !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type);
public/scripts/openai.js+7 -7
@@ -2533,7 +2533,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
2533 let text = '';2533 let text = '';
2534 const swipes = [];2534 const swipes = [];
2535 const toolCalls = [];2535 const toolCalls = [];
2536 const state = { reasoning: '', image: '' };2536 const state = { reasoning: '', images: [] };
2537 while (true) {2537 while (true) {
2538 const { done, value } = await reader.read();2538 const { done, value } = await reader.read();
2539 if (done) return;2539 if (done) return;
@@ -2598,9 +2598,9 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
2598 }2598 }
2599 return data?.delta?.text || '';2599 return data?.delta?.text || '';
2600 } else if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(chat_completion_source)) {2600 } else if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(chat_completion_source)) {
2601 const inlineData = data?.candidates?.[0]?.content?.parts?.find(x => x.inlineData)?.inlineData;2601 const inlineData = data?.candidates?.[0]?.content?.parts?.filter(x => x.inlineData)?.map(x => x.inlineData) || [];
2602 if (inlineData) {2602 if (Array.isArray(inlineData) && inlineData.length > 0) {
2603 state.image = `data:${inlineData.mimeType};base64,${inlineData.data}`;2603 state.images.push(...inlineData.map(x => `data:${x.mimeType};base64,${x.data}`).filter(isDataURL));
2604 }2604 }
2605 if (show_thoughts) {2605 if (show_thoughts) {
2606 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');2606 state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || '');
@@ -2619,9 +2619,9 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov
2619 }2619 }
2620 return data.choices?.[0]?.delta?.content || '';2620 return data.choices?.[0]?.delta?.content || '';
2621 } else if (chat_completion_source === chat_completion_sources.OPENROUTER) {2621 } else if (chat_completion_source === chat_completion_sources.OPENROUTER) {
2622 const imageUrl = data?.choices?.[0]?.delta?.images?.find(x => x.type === 'image_url')?.image_url?.url;2622 const imageUrls = data?.choices?.[0]?.delta?.images?.filter(x => x.type === 'image_url')?.map(x => x?.image_url?.url) || [];
2623 if (imageUrl) {2623 if (Array.isArray(imageUrls) && imageUrls.length > 0) {
2624 state.image = imageUrl;2624 state.images.push(...imageUrls.filter(isDataURL));
2625 }2625 }
2626 if (show_thoughts) {2626 if (show_thoughts) {
2627 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');2627 state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || '');
public/scripts/utils.js+1 -1
@@ -1142,7 +1142,7 @@ export function splitRecursive(input, length, delimiters = ['\n\n', '\n', ' ', '
1142 */1142 */
1143export function isDataURL(str) {1143export function isDataURL(str) {
1144 const regex = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)*;?)?(base64)?,([a-z0-9!$&',()*+;=\-_%.~:@/?#]+)?$/i;1144 const regex = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)*;?)?(base64)?,([a-z0-9!$&',()*+;=\-_%.~:@/?#]+)?$/i;
1145 return regex.test(str);1145 return typeof str === 'string' && regex.test(str);
1146}1146}
11471147
1148/**1148/**
public/style.css+1 -0
@@ -4977,6 +4977,7 @@ a:hover {
4977 align-items: center;4977 align-items: center;
4978 flex-wrap: wrap;4978 flex-wrap: wrap;
4979 gap: 0.5em;4979 gap: 0.5em;
4980 padding-right: var(--mes-right-spacing);
4980}4981}
49814982
4982.mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) {4983.mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) {