diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index d19ace2..4175eff 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -3,7 +3,7 @@ import json import logging import uuid -from quart import Blueprint, Response, jsonify, make_response, request +from quart import Blueprint, jsonify, make_response, request from quart_jwt_extended import ( get_jwt_identity, jwt_refresh_token_required, @@ -12,6 +12,7 @@ from quart_jwt_extended import ( import blueprints.users.models from utils.image_process import analyze_user_image from utils.image_upload import ImageValidationError, process_image +from utils.s3_client import generate_presigned_url as s3_presigned_url from utils.s3_client import get_image as s3_get_image from utils.s3_client import upload_image as s3_upload_image @@ -122,27 +123,14 @@ async def upload_image(): await s3_upload_image(processed_bytes, key, output_content_type) - return jsonify( - { - "image_key": key, - "image_url": f"/api/conversation/image/{key}", - } - ) + return jsonify({"image_key": key}) @conversation_blueprint.get("/image/") @jwt_refresh_token_required async def serve_image(image_key: str): - try: - image_bytes, content_type = await s3_get_image(image_key) - except Exception: - return jsonify({"error": "Image not found"}), 404 - - return Response( - image_bytes, - content_type=content_type, - headers={"Cache-Control": "private, max-age=3600"}, - ) + url = await s3_presigned_url(image_key) + return jsonify({"url": url}) @conversation_blueprint.post("/stream-query") diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 788089e..efd4b58 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -125,7 +125,7 @@ class ConversationService { async uploadImage( file: File, conversationId: string, - ): Promise<{ image_key: string; image_url: string }> { + ): Promise<{ image_key: string }> { const formData = new FormData(); formData.append("file", file); formData.append("conversation_id", conversationId); @@ -147,8 +147,15 @@ class ConversationService { return await response.json(); } - getImageUrl(imageKey: string): string { - return `/api/conversation/image/${imageKey}`; + async getPresignedImageUrl(imageKey: string): Promise { + const response = await userService.fetchWithRefreshToken( + `${this.conversationBaseUrl}/image/${imageKey}`, + ); + if (!response.ok) { + throw new Error("Failed to get image URL"); + } + const data = await response.json(); + return data.url; } async streamQuery( diff --git a/raggr-frontend/src/components/QuestionBubble.tsx b/raggr-frontend/src/components/QuestionBubble.tsx index b131c11..a3678e4 100644 --- a/raggr-frontend/src/components/QuestionBubble.tsx +++ b/raggr-frontend/src/components/QuestionBubble.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { cn } from "../lib/utils"; import { conversationService } from "../api/conversationService"; @@ -7,6 +8,20 @@ type QuestionBubbleProps = { }; export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { + const [imageUrl, setImageUrl] = useState(null); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + if (!image_key) return; + conversationService + .getPresignedImageUrl(image_key) + .then(setImageUrl) + .catch((err) => { + console.error("Failed to load image:", err); + setImageError(true); + }); + }, [image_key]); + return (
{ "shadow-sm shadow-leaf/10", )} > - {image_key && ( + {imageError && ( +
+ 🖼️ + Image failed to load +
+ )} + {imageUrl && ( Uploaded image diff --git a/utils/s3_client.py b/utils/s3_client.py index 443ef82..4d941a5 100644 --- a/utils/s3_client.py +++ b/utils/s3_client.py @@ -47,6 +47,16 @@ async def get_image(key: str) -> tuple[bytes, str]: return body, content_type +async def generate_presigned_url(key: str, expires_in: int = 3600) -> str: + async with _get_client() as client: + url = await client.generate_presigned_url( + "get_object", + Params={"Bucket": S3_BUCKET_NAME, "Key": key}, + ExpiresIn=expires_in, + ) + return url + + async def delete_image(key: str) -> None: async with _get_client() as client: await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)