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
This commit is contained in:
Russell Ballestrini 2025-12-07 08:47:36 -05:00
parent d9976ffe31
commit 19b8ce4037

View file

@ -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) {