Merge pull request #2805 from SillyTavern/gemini-vision-fix Fix Gemini multimodal with JPG images
Signed| @@ -20,7 +20,7 @@ export async function getMultimodalCaption(base64Img, prompt) { | |||
| 20 | 20 | ||
| 21 | throwIfInvalidModel(useReverseProxy); | 21 | throwIfInvalidModel(useReverseProxy); |
| 22 | 22 | ||
| 23 | const noPrefix = ['google', 'ollama', 'llamacpp'].includes(extension_settings.caption.multimodal_api); | 23 | const noPrefix = ['ollama', 'llamacpp'].includes(extension_settings.caption.multimodal_api); |
| 24 | 24 | ||
| 25 | if (noPrefix && base64Img.startsWith('data:image/')) { | 25 | if (noPrefix && base64Img.startsWith('data:image/')) { |
| 26 | base64Img = base64Img.split(',')[1]; | 26 | base64Img = base64Img.split(',')[1]; |
| @@ -28,7 +28,6 @@ export async function getMultimodalCaption(base64Img, prompt) { | |||
| 28 | 28 | ||
| 29 | // OpenRouter has a payload limit of ~2MB. Google is 4MB, but we love democracy. | 29 | // OpenRouter has a payload limit of ~2MB. Google is 4MB, but we love democracy. |
| 30 | // Ooba requires all images to be JPEGs. Koboldcpp just asked nicely. | 30 | // Ooba requires all images to be JPEGs. Koboldcpp just asked nicely. |
| 31 | const isGoogle = extension_settings.caption.multimodal_api === 'google'; | ||
| 32 | const isOllama = extension_settings.caption.multimodal_api === 'ollama'; | 31 | const isOllama = extension_settings.caption.multimodal_api === 'ollama'; |
| 33 | const isLlamaCpp = extension_settings.caption.multimodal_api === 'llamacpp'; | 32 | const isLlamaCpp = extension_settings.caption.multimodal_api === 'llamacpp'; |
| 34 | const isCustom = extension_settings.caption.multimodal_api === 'custom'; | 33 | const isCustom = extension_settings.caption.multimodal_api === 'custom'; |
| @@ -40,10 +39,6 @@ export async function getMultimodalCaption(base64Img, prompt) { | |||
| 40 | if ((['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { | 39 | if ((['google', 'openrouter'].includes(extension_settings.caption.multimodal_api) && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) { |
| 41 | const maxSide = 1024; | 40 | const maxSide = 1024; |
| 42 | base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg'); | 41 | base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg'); |
| 43 | |||
| 44 | if (isGoogle) { | ||
| 45 | base64Img = base64Img.split(',')[1]; | ||
| 46 | } | ||
| 47 | } | 42 | } |
| 48 | 43 | ||
| 49 | const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : ''; | 44 | const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : ''; |
| @@ -47,6 +47,7 @@ import { SECRET_KEYS, secret_state, writeSecret } from './secrets.js'; | |||
| 47 | 47 | ||
| 48 | import { getEventSourceStream } from './sse-stream.js'; | 48 | import { getEventSourceStream } from './sse-stream.js'; |
| 49 | import { | 49 | import { |
| 50 | createThumbnail, | ||
| 50 | delay, | 51 | delay, |
| 51 | download, | 52 | download, |
| 52 | getBase64Async, | 53 | getBase64Async, |
| @@ -2440,15 +2441,14 @@ class Message { | |||
| 2440 | if (!response.ok) throw new Error('Failed to fetch image'); | 2441 | if (!response.ok) throw new Error('Failed to fetch image'); |
| 2441 | const blob = await response.blob(); | 2442 | const blob = await response.blob(); |
| 2442 | image = await getBase64Async(blob); | 2443 | image = await getBase64Async(blob); |
| 2443 | if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) { | ||
| 2444 | image = image.split(',')[1]; | ||
| 2445 | } | ||
| 2446 | } catch (error) { | 2444 | } catch (error) { |
| 2447 | console.error('Image adding skipped', error); | 2445 | console.error('Image adding skipped', error); |
| 2448 | return; | 2446 | return; |
| 2449 | } | 2447 | } |
| 2450 | } | 2448 | } |
| 2451 | 2449 | ||
| 2450 | image = await this.compressImage(image); | ||
| 2451 | |||
| 2452 | const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality; | 2452 | const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality; |
| 2453 | this.content = [ | 2453 | this.content = [ |
| 2454 | { type: 'text', text: textContent }, | 2454 | { type: 'text', text: textContent }, |
| @@ -2464,6 +2464,29 @@ class Message { | |||
| 2464 | } | 2464 | } |
| 2465 | } | 2465 | } |
| 2466 | 2466 | ||
| 2467 | /** | ||
| 2468 | * Compress an image if it exceeds the size threshold for the current chat completion source. | ||
| 2469 | * @param {string} image Data URL of the image. | ||
| 2470 | * @returns {Promise<string>} Compressed image as a Data URL. | ||
| 2471 | */ | ||
| 2472 | async compressImage(image) { | ||
| 2473 | if ([chat_completion_sources.OPENROUTER, chat_completion_sources.MAKERSUITE].includes(oai_settings.chat_completion_source)) { | ||
| 2474 | const sizeThreshold = 2 * 1024 * 1024; | ||
| 2475 | const dataSize = image.length * 0.75; | ||
| 2476 | const maxSide = 1024; | ||
| 2477 | if (dataSize > sizeThreshold) { | ||
| 2478 | image = await createThumbnail(image, maxSide); | ||
| 2479 | } | ||
| 2480 | } | ||
| 2481 | return image; | ||
| 2482 | } | ||
| 2483 | |||
| 2484 | /** | ||
| 2485 | * Get the token cost of an image. | ||
| 2486 | * @param {string} dataUrl Data URL of the image. | ||
| 2487 | * @param {string} quality String representing the quality of the image. Can be 'low', 'auto', or 'high'. | ||
| 2488 | * @returns {Promise<number>} The token cost of the image. | ||
| 2489 | */ | ||
| 2467 | async getImageTokenCost(dataUrl, quality) { | 2490 | async getImageTokenCost(dataUrl, quality) { |
| 2468 | if (quality === 'low') { | 2491 | if (quality === 'low') { |
| 2469 | return Message.tokensPerImage; | 2492 | return Message.tokensPerImage; |
| @@ -22,8 +22,8 @@ router.post('/caption-image', jsonParser, async (request, response) => { | |||
| 22 | { text: request.body.prompt }, | 22 | { text: request.body.prompt }, |
| 23 | { | 23 | { |
| 24 | inlineData: { | 24 | inlineData: { |
| 25 | mimeType: 'image/png', // It needs to specify a MIME type in data if it's not a PNG | 25 | mimeType: mimeType, |
| 26 | data: mimeType === 'image/png' ? base64Data : request.body.image, | 26 | data: base64Data, |
| 27 | }, | 27 | }, |
| 28 | }], | 28 | }], |
| 29 | }], | 29 | }], |
| @@ -335,10 +335,12 @@ function convertGooglePrompt(messages, model, useSysPrompt = false, charName = ' | |||
| 335 | if (part.type === 'text') { | 335 | if (part.type === 'text') { |
| 336 | parts.push({ text: part.text }); | 336 | parts.push({ text: part.text }); |
| 337 | } else if (part.type === 'image_url' && isMultimodal) { | 337 | } else if (part.type === 'image_url' && isMultimodal) { |
| 338 | const mimeType = part.image_url.url.split(';')[0].split(':')[1]; | ||
| 339 | const base64Data = part.image_url.url.split(',')[1]; | ||
| 338 | parts.push({ | 340 | parts.push({ |
| 339 | inlineData: { | 341 | inlineData: { |
| 340 | mimeType: 'image/png', | 342 | mimeType: mimeType, |
| 341 | data: part.image_url.url, | 343 | data: base64Data, |
| 342 | }, | 344 | }, |
| 343 | }); | 345 | }); |
| 344 | hasImage = true; | 346 | hasImage = true; |