Add image upload and vision analysis to Ask Simba chat

Users can now attach images in the web chat for Simba to analyze using
Ollama's gemma3 vision model. Images are stored in Garage (S3-compatible)
and displayed in chat history.

Also fixes aerich migration config by extracting TORTOISE_CONFIG into a
standalone config/db.py module, removing the stale aerich_config.py, and
adding missing MODELS_STATE to migration 3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:03:19 -04:00
parent ac9c821ec7
commit 0415610d64
17 changed files with 501 additions and 58 deletions

62
utils/image_upload.py Normal file
View File

@@ -0,0 +1,62 @@
import io
import logging
from PIL import Image
from pillow_heif import register_heif_opener
register_heif_opener()
logging.basicConfig(level=logging.INFO)
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}
MAX_DIMENSION = 1920
class ImageValidationError(Exception):
pass
def process_image(file_bytes: bytes, content_type: str) -> tuple[bytes, str]:
"""Validate, resize, and strip EXIF from an uploaded image.
Returns processed bytes and the output content type (always image/jpeg or image/png or image/webp).
"""
if content_type not in ALLOWED_TYPES:
raise ImageValidationError(
f"Unsupported image type: {content_type}. "
f"Allowed: JPEG, PNG, WebP, HEIC"
)
img = Image.open(io.BytesIO(file_bytes))
# Resize if too large
width, height = img.size
if max(width, height) > MAX_DIMENSION:
ratio = MAX_DIMENSION / max(width, height)
new_size = (int(width * ratio), int(height * ratio))
img = img.resize(new_size, Image.LANCZOS)
logging.info(
f"Resized image from {width}x{height} to {new_size[0]}x{new_size[1]}"
)
# Strip EXIF by copying pixel data to a new image
clean_img = Image.new(img.mode, img.size)
clean_img.putdata(list(img.getdata()))
# Convert HEIC/HEIF to JPEG; otherwise keep original format
if content_type in {"image/heic", "image/heif"}:
output_format = "JPEG"
output_content_type = "image/jpeg"
elif content_type == "image/png":
output_format = "PNG"
output_content_type = "image/png"
elif content_type == "image/webp":
output_format = "WEBP"
output_content_type = "image/webp"
else:
output_format = "JPEG"
output_content_type = "image/jpeg"
buf = io.BytesIO()
clean_img.save(buf, format=output_format, quality=85)
return buf.getvalue(), output_content_type