Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions backend/app/api/upload.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""File upload API for chat — saves files to agent workspace and extracts text."""

import base64
import os
import uuid
from pathlib import Path

from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Form
from loguru import logger
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile

from app.core.security import get_current_user
from app.models.user import User
from app.services.chat_image_preview import (
VisionImagePreviewError,
build_vision_image_data_url,
)
from app.services.storage import ensure_local_path, get_storage_backend, guess_content_type, normalize_storage_key

router = APIRouter(prefix="/chat", tags=["chat"])
Expand All @@ -23,10 +26,7 @@
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
EXTRACTABLE = TEXT_EXTENSIONS | OFFICE_EXTENSIONS

MIME_MAP = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
}
MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024


def extract_text(file_path: Path, extension: str) -> str:
Expand Down Expand Up @@ -147,12 +147,16 @@ async def upload_file(
is_image = ext in IMAGE_EXTENSIONS
image_data_url = ""
if is_image:
# For images: generate base64 data URL for vision models
if len(content) > 10 * 1024 * 1024: # 10MB limit
# Store the original, but send a bounded preview to vision models so a
# batch of images still fits in a single WebSocket message.
if len(content) > MAX_CHAT_IMAGE_BYTES:
raise HTTPException(status_code=400, detail="Image too large (max 10MB)")
mime = MIME_MAP.get(ext, "image/png")
b64 = base64.b64encode(content).decode("ascii")
image_data_url = f"data:{mime};base64,{b64}"
try:
image_data_url = build_vision_image_data_url(content, ext)
except VisionImagePreviewError as exc:
raise HTTPException(
status_code=400, detail="Invalid or unsupported image"
) from exc
extracted = f"[图片文件: {file.filename},需要视觉模型分析]"
elif ext in EXTRACTABLE:
extracted = extract_text(save_path, ext)
Expand Down
73 changes: 73 additions & 0 deletions backend/app/services/chat_image_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Bounded previews for image-bearing chat messages."""

import base64
from io import BytesIO

from PIL import Image, ImageOps, UnidentifiedImageError

MAX_VISION_IMAGE_BYTES = 512 * 1024
MAX_VISION_IMAGE_EDGE = 1568

_MIME_BY_EXTENSION = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
}


class VisionImagePreviewError(ValueError):
"""The uploaded image cannot be converted into a safe preview."""


def build_vision_image_data_url(content: bytes, extension: str) -> str:
"""Return a bounded data URL while leaving the stored original intact."""
try:
with Image.open(BytesIO(content)) as opened:
opened.load()
image = ImageOps.exif_transpose(opened)
if (
len(content) <= MAX_VISION_IMAGE_BYTES
and max(image.size) <= MAX_VISION_IMAGE_EDGE
):
mime = _MIME_BY_EXTENSION.get(extension, "image/png")
encoded = base64.b64encode(content).decode("ascii")
return f"data:{mime};base64,{encoded}"

if image.mode in {"RGBA", "LA"} or (
image.mode == "P" and "transparency" in image.info
):
rgba = image.convert("RGBA")
background = Image.new("RGB", rgba.size, "white")
background.paste(rgba, mask=rgba.getchannel("A"))
image = background
else:
image = image.convert("RGB")

image.thumbnail(
(MAX_VISION_IMAGE_EDGE, MAX_VISION_IMAGE_EDGE),
Image.Resampling.LANCZOS,
)
quality = 85
for _ in range(16):
output = BytesIO()
image.save(output, format="JPEG", quality=quality, optimize=True)
preview = output.getvalue()
if len(preview) <= MAX_VISION_IMAGE_BYTES:
encoded = base64.b64encode(preview).decode("ascii")
return f"data:image/jpeg;base64,{encoded}"
if quality > 55:
quality -= 10
else:
width, height = image.size
image = image.resize(
(max(1, int(width * 0.8)), max(1, int(height * 0.8))),
Image.Resampling.LANCZOS,
)
quality = 75
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise VisionImagePreviewError("Invalid or unsupported image") from exc

raise VisionImagePreviewError("Could not create a bounded image preview")
43 changes: 43 additions & 0 deletions backend/tests/test_chat_upload_images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Regression tests for bounded chat image payloads."""

import base64
import os
from io import BytesIO

from PIL import Image

from app.services.chat_image_preview import (
MAX_VISION_IMAGE_BYTES,
MAX_VISION_IMAGE_EDGE,
build_vision_image_data_url,
)


def _decode_data_url(data_url: str) -> bytes:
return base64.b64decode(data_url.split(",", 1)[1])


def test_small_chat_image_keeps_original_encoding() -> None:
output = BytesIO()
Image.new("RGB", (32, 24), "red").save(output, format="PNG")
original = output.getvalue()

data_url = build_vision_image_data_url(original, ".png")

assert data_url.startswith("data:image/png;base64,")
assert _decode_data_url(data_url) == original


def test_large_chat_image_is_bounded_for_multi_image_messages() -> None:
image = Image.frombytes("RGB", (2400, 1800), os.urandom(2400 * 1800 * 3))
original = BytesIO()
image.save(original, format="JPEG", quality=100)
assert len(original.getvalue()) > MAX_VISION_IMAGE_BYTES

data_url = build_vision_image_data_url(original.getvalue(), ".jpg")
preview = _decode_data_url(data_url)

assert data_url.startswith("data:image/jpeg;base64,")
assert len(preview) <= MAX_VISION_IMAGE_BYTES
with Image.open(BytesIO(preview)) as bounded:
assert max(bounded.size) <= MAX_VISION_IMAGE_EDGE