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
Signed| @@ -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 | } |
| 3128 | 3128 | ||
| 3129 | /** | 3129 | /** |
| @@ -3309,8 +3309,8 @@ class StreamingProcessor { | |||
| 3309 | chat[messageId].swipe_info.push(...swipeInfoArray); | 3309 | chat[messageId].swipe_info.push(...swipeInfoArray); |
| 3310 | } | 3310 | } |
| 3311 | 3311 | ||
| 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 | } |
| 3316 | 3316 | ||
| @@ -3408,7 +3408,7 @@ class StreamingProcessor { | |||
| 3408 | } | 3408 | } |
| 3409 | // Get the updated reasoning string into the handler | 3409 | // 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; |
| 4975 | 4975 | ||
| 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 | } |
| 5022 | 5022 | ||
| 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 options | 5641 | * @param {object} [options] Extraction options |
| 5642 | * @param {string} [options.mainApi] Main API to use | 5642 | * @param {string} [options.mainApi] Main API to use |
| 5643 | * @param {string} [options.chatCompletionSource] Chat completion source | 5643 | * @param {string} [options.chatCompletionSource] Chat completion source |
| 5644 | * @returns {string} Extracted image | 5644 | * @returns {string[]} Extracted images or empty array |
| 5645 | */ | 5645 | */ |
| 5646 | function extractImageFromData(data, { mainApi = null, chatCompletionSource = null } = {}) { | 5646 | function 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 URLs | 5662 | // TODO: Handle remote URLs |
| 5663 | } | 5663 | } |
| @@ -5665,7 +5665,7 @@ function extractImageFromData(data, { mainApi = null, chatCompletionSource = nul | |||
| 5665 | } break; | 5665 | } break; |
| 5666 | } | 5666 | } |
| 5667 | 5667 | ||
| 5668 | return undefined; | 5668 | return []; |
| 5669 | } | 5669 | } |
| 5670 | 5670 | ||
| 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 object | 6013 | * @param {object} message Message object |
| 6014 | * @param {object} sources Image sources | 6014 | * @param {object} sources Image sources |
| 6015 | * @param {string} [sources.imageUrl] Image URL | 6015 | * @param {string[]} [sources.imageUrls] Image URLs |
| 6016 | * | 6016 | * |
| 6017 | * @returns {Promise<void>} | 6017 | * @returns {Promise<void>} |
| 6018 | */ | 6018 | */ |
| 6019 | async function processImageAttachment(message, { imageUrl }) { | 6019 | async function processImageAttachment(message, { imageUrls }) { |
| 6020 | if (!imageUrl) { | 6020 | if (!Array.isArray(imageUrls) || imageUrls.length === 0) { |
| 6021 | return; | 6021 | return; |
| 6022 | } | 6022 | } |
| 6023 | 6023 | ||
| 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 | } | ||
| 6032 | 6038 | ||
| 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 tooltip | 6048 | * @property {string} [title] Message tooltip |
| 6043 | * @property {string[]} [swipes] Extra swipes | 6049 | * @property {string[]} [swipes] Extra swipes |
| 6044 | * @property {string} [reasoning] Message reasoning | 6050 | * @property {string} [reasoning] Message reasoning |
| 6045 | * @property {string} [imageUrl] Link to an image | 6051 | * @property {string[]} [imageUrls] Links to images |
| 6046 | * | 6052 | * |
| 6047 | * @typedef {object} SaveReplyResult | 6053 | * @typedef {object} SaveReplyResult |
| 6048 | * @property {string} type Type of generation | 6054 | * @property {string} type Type of generation |
| 6049 | * @property {string} getMessage Generated message | 6055 | * @property {string} getMessage Generated message |
| 6050 | */ | 6056 | */ |
| 6051 | export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrl = '' }) { | 6057 | export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrls = [] }) { |
| 6052 | // Backward compatibility | 6058 | // 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 | } |
| 6057 | 6063 | ||
| 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 | } |
| 6180 | 6186 | ||
| 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); |
| 6183 | 6189 | ||
| 6184 | !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type); | 6190 | !fromStreaming && await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id, type); |
| @@ -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 || ''); |
| @@ -1142,7 +1142,7 @@ export function splitRecursive(input, length, delimiters = ['\n\n', '\n', ' ', ' | |||
| 1142 | */ | 1142 | */ |
| 1143 | export function isDataURL(str) { | 1143 | export 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 | } |
| 1147 | 1147 | ||
| 1148 | /** | 1148 | /** |
| @@ -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 | } |
| 4981 | 4982 | ||
| 4982 | .mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) { | 4983 | .mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) { |