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 | 3122 | this.reasoningHandler = new ReasoningHandler(timeStarted); |
| 3123 | 3123 | /** @type {PromptReasoning} */ |
| 3124 | 3124 | this.promptReasoning = promptReasoning; |
| 3125 | 3125 | /** @type {string[]} */ |
| 3126 | 3126 | this.imageimages = ''[]; |
| 3127 | 3127 | } |
| 3128 | 3128 | |
| 3129 | 3129 | /** |
| @@ -3309,8 +3309,8 @@ class StreamingProcessor { | ||
| 3309 | 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 | 3313 | await processImageAttachment(chat[messageId], { imageUrlimageUrls: this.imageimages }); |
| 3314 | 3314 | appendMediaToMessage(chat[messageId], $(this.messageDom)); |
| 3315 | 3315 | } |
| 3316 | 3316 | |
| @@ -3408,7 +3408,7 @@ class StreamingProcessor { | ||
| 3408 | 3408 | } |
| 3409 | 3409 | // Get the updated reasoning string into the handler |
| 3410 | 3410 | this.reasoningHandler.updateReasoning(this.messageId, state?.reasoning); |
| 3411 | 3411 | this.imageimages = state?.imageimages ?? ''[]; |
| 3412 | 3412 | await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text); |
| 3413 | 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 | 4970 | let getMessage = extractMessageFromData(data); |
| 4971 | 4971 | let title = extractTitleFromData(data); |
| 4972 | 4972 | let reasoning = extractReasoningFromData(data); |
| 4973 | 4973 | let imageUrlimageUrls = extractImageFromDataextractImagesFromData(data); |
| 4974 | 4974 | kobold_horde_model = title; |
| 4975 | 4975 | |
| 4976 | 4976 | const swipes = extractMultiSwipes(data, type); |
| @@ -5014,10 +5014,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro | ||
| 5014 | 5014 | else { |
| 5015 | 5015 | // Without streaming we'll be having a full message on continuation. Treat it as a last chunk. |
| 5016 | 5016 | if (originalType !== 'continue') { |
| 5017 | 5017 | ({ type, getMessage } = await saveReply({ type, getMessage, title, swipes, reasoning, imageUrlimageUrls })); |
| 5018 | 5018 | } |
| 5019 | 5019 | else { |
| 5020 | 5020 | ({ type, getMessage } = await saveReply({ type: 'appendFinal', getMessage, title, swipes, reasoning, imageUrlimageUrls })); |
| 5021 | 5021 | } |
| 5022 | 5022 | |
| 5023 | 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 | 5641 | * @param {object} [options] Extraction options |
| 5642 | 5642 | * @param {string} [options.mainApi] Main API to use |
| 5643 | 5643 | * @param {string} [options.chatCompletionSource] Chat completion source |
| 5644 | 5644 | * @returns {string[]} Extracted imageimages or empty array |
| 5645 | 5645 | */ |
| 5646 | 5646 | function extractImageFromDataextractImagesFromData(data, { mainApi = null, chatCompletionSource = null } = {}) { |
| 5647 | 5647 | switch (mainApi ?? main_api) { |
| 5648 | 5648 | case 'openai': { |
| 5649 | 5649 | switch (chatCompletionSource ?? oai_settings.chat_completion_source) { |
| 5650 | 5650 | case chat_completion_sources.VERTEXAI: |
| 5651 | 5651 | case chat_completion_sources.MAKERSUITE: { |
| 5652 | 5652 | const inlineData = data?.responseContent?.parts?.findfilter(x => x.inlineData)?.map(x => x.inlineData); |
| 5653 | 5653 | if (Array.isArray(inlineData) && inlineData.length > 0) { |
| 5654 | 5654 | return inlineData.map(x => `data:${inlineDatax.mimeType};base64,${inlineDatax.data}`).filter(isDataURL); |
| 5655 | 5655 | } |
| 5656 | 5656 | } break; |
| 5657 | 5657 | case chat_completion_sources.OPENROUTER: { |
| 5658 | 5658 | const imageUrl = data?.choices[0]?.message?.images?.findfilter(x => x.type === 'image_url')?.map(x => x?.image_url?.url); |
| 5659 | 5659 | if (isDataURLArray.isArray(imageUrl) && imageUrl.length > 0) { |
| 5660 | 5660 | return imageUrl.filter(isDataURL); |
| 5661 | 5661 | } |
| 5662 | 5662 | // TODO: Handle remote URLs |
| 5663 | 5663 | } |
| @@ -5665,7 +5665,7 @@ function extractImageFromData(data, { mainApi = null, chatCompletionSource = nul | ||
| 5665 | 5665 | } break; |
| 5666 | 5666 | } |
| 5667 | 5667 | |
| 5668 | 5668 | return undefined[]; |
| 5669 | 5669 | } |
| 5670 | 5670 | |
| 5671 | 5671 | /** |
| @@ -6012,23 +6012,29 @@ export function cleanUpMessage({ getMessage, isImpersonate, isContinue, displayI | ||
| 6012 | 6012 | * Adds an image to the message. |
| 6013 | 6013 | * @param {object} message Message object |
| 6014 | 6014 | * @param {object} sources Image sources |
| 6015 | 6015 | * @param {string[]} [sources.imageUrlimageUrls] Image URLURLs |
| 6016 | 6016 | * |
| 6017 | 6017 | * @returns {Promise<void>} |
| 6018 | 6018 | */ |
| 6019 | 6019 | async function processImageAttachment(message, { imageUrlimageUrls }) { |
| 6020 | - if (!imageUrl) { | |
| 6020 | + if (!Array.isArray(imageUrls) || imageUrls.length === 0) { | |
| 6021 | 6021 | return; |
| 6022 | 6022 | } |
| 6023 | 6023 | |
| 6024 | + for (const [index, imageUrl] of imageUrls.entries()) { | |
| 6025 | + if (!imageUrl) { | |
| 6026 | + continue; | |
| 6027 | + } | |
| 6028 | + | |
| 6024 | 6029 | let url = imageUrl; |
| 6025 | 6030 | if (isDataURL(url)) { |
| 6026 | 6031 | const fileName = `inline_image_${Date.now().toString()}_${index}`; |
| 6027 | 6032 | const [mime, base64] = /^data:(.*?);base64,(.*)$/.exec(imageUrl).slice(1); |
| 6028 | 6033 | url = await saveBase64AsFile(base64, message.name, fileName, mime.split('/')[1]); |
| 6029 | 6034 | } |
| 6030 | 6035 | saveImageToMessage({ image: url, inline: true }, message); |
| 6031 | 6036 | } |
| 6037 | +} | |
| 6032 | 6038 | |
| 6033 | 6039 | /** |
| 6034 | 6040 | * Saves a resulting message to the chat. |
| @@ -6042,17 +6048,17 @@ async function processImageAttachment(message, { imageUrl }) { | ||
| 6042 | 6048 | * @property {string} [title] Message tooltip |
| 6043 | 6049 | * @property {string[]} [swipes] Extra swipes |
| 6044 | 6050 | * @property {string} [reasoning] Message reasoning |
| 6045 | 6051 | * @property {string[]} [imageUrlimageUrls] LinkLinks to an imageimages |
| 6046 | 6052 | * |
| 6047 | 6053 | * @typedef {object} SaveReplyResult |
| 6048 | 6054 | * @property {string} type Type of generation |
| 6049 | 6055 | * @property {string} getMessage Generated message |
| 6050 | 6056 | */ |
| 6051 | 6057 | export async function saveReply({ type, getMessage, fromStreaming = false, title = '', swipes = [], reasoning = '', imageUrlimageUrls = ''[] }) { |
| 6052 | 6058 | // Backward compatibility |
| 6053 | 6059 | if (arguments.length > 1 && typeof arguments[0] !== 'object') { |
| 6054 | 6060 | console.trace('saveReply called with positional arguments. Please use an object instead.'); |
| 6055 | 6061 | [type, getMessage, fromStreaming, title, swipes, reasoning, imageUrlimageUrls] = arguments; |
| 6056 | 6062 | } |
| 6057 | 6063 | |
| 6058 | 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 | 6094 | chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); |
| 6089 | 6095 | chat[chat.length - 1]['extra']['reasoning'] = reasoning; |
| 6090 | 6096 | chat[chat.length - 1]['extra']['reasoning_duration'] = null; |
| 6091 | 6097 | await processImageAttachment(chat[chat.length - 1], { imageUrlimageUrls }); |
| 6092 | 6098 | if (power_user.message_token_count_enabled) { |
| 6093 | 6099 | const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; |
| 6094 | 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 | 6118 | chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); |
| 6113 | 6119 | chat[chat.length - 1]['extra']['reasoning'] = reasoning; |
| 6114 | 6120 | chat[chat.length - 1]['extra']['reasoning_duration'] = null; |
| 6115 | 6121 | await processImageAttachment(chat[chat.length - 1], { imageUrlimageUrls }); |
| 6116 | 6122 | if (power_user.message_token_count_enabled) { |
| 6117 | 6123 | const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; |
| 6118 | 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 | 6138 | chat[chat.length - 1]['extra']['api'] = getGeneratingApi(); |
| 6133 | 6139 | chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); |
| 6134 | 6140 | chat[chat.length - 1]['extra']['reasoning'] += reasoning; |
| 6135 | 6141 | await processImageAttachment(chat[chat.length - 1], { imageUrlimageUrls }); |
| 6136 | 6142 | // We don't know if the reasoning duration extended, so we don't update it here on purpose. |
| 6137 | 6143 | if (power_user.message_token_count_enabled) { |
| 6138 | 6144 | const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; |
| @@ -6178,7 +6184,7 @@ export async function saveReply({ type, getMessage, fromStreaming = false, title | ||
| 6178 | 6184 | chat[chat.length - 1]['extra']['gen_id'] = group_generation_id; |
| 6179 | 6185 | } |
| 6180 | 6186 | |
| 6181 | 6187 | await processImageAttachment(chat[chat.length - 1], { imageUrlimageUrls }); |
| 6182 | 6188 | const chat_id = (chat.length - 1); |
| 6183 | 6189 | |
| 6184 | 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 | 2533 | let text = ''; |
| 2534 | 2534 | const swipes = []; |
| 2535 | 2535 | const toolCalls = []; |
| 2536 | 2536 | const state = { reasoning: '', imageimages: ''[] }; |
| 2537 | 2537 | while (true) { |
| 2538 | 2538 | const { done, value } = await reader.read(); |
| 2539 | 2539 | if (done) return; |
| @@ -2598,9 +2598,9 @@ export function getStreamingReply(data, state, { chatCompletionSource = null, ov | ||
| 2598 | 2598 | } |
| 2599 | 2599 | return data?.delta?.text || ''; |
| 2600 | 2600 | } else if ([chat_completion_sources.MAKERSUITE, chat_completion_sources.VERTEXAI].includes(chat_completion_source)) { |
| 2601 | 2601 | const inlineData = data?.candidates?.[0]?.content?.parts?.findfilter(x => x.inlineData)?.map(x => x.inlineData) || []; |
| 2602 | 2602 | if (Array.isArray(inlineData) && inlineData.length > 0) { |
| 2603 | 2603 | state.imageimages.push(...inlineData.map(x => `data:${inlineDatax.mimeType};base64,${inlineDatax.data}`).filter(isDataURL)); |
| 2604 | 2604 | } |
| 2605 | 2605 | if (show_thoughts) { |
| 2606 | 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 | 2620 | return data.choices?.[0]?.delta?.content || ''; |
| 2621 | 2621 | } else if (chat_completion_source === chat_completion_sources.OPENROUTER) { |
| 2622 | 2622 | const imageUrlimageUrls = data?.choices?.[0]?.delta?.images?.findfilter(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 | 2626 | if (show_thoughts) { |
| 2627 | 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 | 1143 | export function isDataURL(str) { |
| 1144 | 1144 | const regex = /^data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)*;?)?(base64)?,([a-z0-9!$&',()*+;=\-_%.~:@/?#]+)?$/i; |
| 1145 | 1145 | return typeof str === 'string' && regex.test(str); |
| 1146 | 1146 | } |
| 1147 | 1147 | |
| 1148 | 1148 | /** |
| @@ -4977,6 +4977,7 @@ a:hover { | ||
| 4977 | 4977 | align-items: center; |
| 4978 | 4978 | flex-wrap: wrap; |
| 4979 | 4979 | gap: 0.5em; |
| 4980 | + padding-right: var(--mes-right-spacing); | |
| 4980 | 4981 | } |
| 4981 | 4982 | |
| 4982 | 4983 | .mes_media_wrapper:not(:empty)~.mes_file_wrapper:not(:empty) { |