From 19b8ce40370721e6f6591febef6723e21feb783b Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Sun, 7 Dec 2025 08:47:36 -0500 Subject: [PATCH] Add external image URL support via CORS proxy - Fetch external images through proxy.unturf.com (respects robots.txt) - Convert fetched images to base64 for vision API - Cache both fetched images and descriptions - Shows "Fetching image..." for external URLs, "Generating description..." for base64 - Handles 403 responses when blocked by robots.txt --- templates/chat.html | 92 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/templates/chat.html b/templates/chat.html index 3e8e768..5279bb1 100644 --- a/templates/chat.html +++ b/templates/chat.html @@ -137,6 +137,10 @@ let isPlayingTTS = false; let visionAvailable = false; let visionModel = null; const imageDescriptionCache = new Map(); // Cache descriptions by image src hash +const imageBase64Cache = new Map(); // Cache fetched external images as base64 + +// CORS proxy for ethical external image fetching (respects robots.txt) +const CORS_PROXY_URL = 'https://proxy.unturf.com/api/fetch'; // Check vision availability on load async function initVisionCapability() { @@ -164,6 +168,74 @@ function hashString(str) { return hash.toString(); } +// Check if URL is an external image URL +function isExternalImageUrl(src) { + if (!src) return false; + if (src.startsWith('data:')) return false; + try { + const url = new URL(src); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +// Fetch external image via CORS proxy and convert to base64 +async function fetchImageAsBase64(imageUrl) { + const cacheKey = hashString(imageUrl); + if (imageBase64Cache.has(cacheKey)) { + return imageBase64Cache.get(cacheKey); + } + + try { + // Use CORS proxy for ethical fetching (handles robots.txt server-side) + const proxyUrl = `${CORS_PROXY_URL}?uri_target=${encodeURIComponent(imageUrl)}`; + const response = await fetch(proxyUrl); + + if (!response.ok) { + if (response.status === 403) { + console.warn(`Image blocked by robots.txt: ${imageUrl}`); + return null; + } + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + + // Verify it's actually an image + if (!blob.type.startsWith('image/')) { + console.warn(`Not an image: ${imageUrl} (${blob.type})`); + return null; + } + + // Convert to base64 + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + const base64 = reader.result; + imageBase64Cache.set(cacheKey, base64); + resolve(base64); + }; + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch (e) { + console.warn(`Failed to fetch image: ${imageUrl}`, e); + return null; + } +} + +// Get base64 image data (handles both base64 and external URLs) +async function getImageBase64(imgSrc) { + if (imgSrc.startsWith('data:image')) { + return imgSrc; + } + if (isExternalImageUrl(imgSrc)) { + return await fetchImageAsBase64(imgSrc); + } + return null; +} + // Fetch description for an image async function getImageDescription(imgSrc) { const cacheKey = hashString(imgSrc); @@ -171,11 +243,17 @@ async function getImageDescription(imgSrc) { return imageDescriptionCache.get(cacheKey); } + // Get base64 version of image (fetch if external) + const base64Image = await getImageBase64(imgSrc); + if (!base64Image) { + return null; + } + try { const response = await fetch('/vision/describe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ image: imgSrc }) + body: JSON.stringify({ image: base64Image }) }); const data = await response.json(); if (data.description) { @@ -197,16 +275,22 @@ function setupImageHoverDescriptions() { messagesContainer.addEventListener('mouseover', async (e) => { if (!visionAvailable) return; - // Check if target is an image with base64 data + // Check if target is an image const img = e.target; if (img.tagName !== 'IMG') return; - if (!img.src || !img.src.startsWith('data:image')) return; + if (!img.src) return; + + // Handle both base64 and external URLs + const isBase64 = img.src.startsWith('data:image'); + const isExternal = isExternalImageUrl(img.src); + if (!isBase64 && !isExternal) return; + if (img.dataset.visionProcessing || img.dataset.visionDone) return; // Mark as processing to avoid duplicate requests img.dataset.visionProcessing = 'true'; img.style.cursor = 'wait'; - img.title = 'Generating description...'; + img.title = isExternal ? 'Fetching image...' : 'Generating description...'; const description = await getImageDescription(img.src); if (description) {