5 Commits

Author SHA1 Message Date
ryan
be600e78d6 Merge pull request 'Fix images not sending in existing conversations' (#19) from fix/image-in-existing-conversations into main
Reviewed-on: #19
2026-04-04 09:08:46 -04:00
Ryan Chen
b6576fb2fd Fix images not sending in existing conversations
Add missing pendingImage, onImageSelect, and onClearImage props to the
MessageInput rendered in the active chat footer, matching the homepage version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:07:21 -04:00
ryan
bb3ef4fe95 Merge pull request 'Fix 401 on image serving with presigned S3 URLs' (#17) from fix/image-presigned-urls into main
Reviewed-on: #17
2026-04-04 08:55:43 -04:00
Ryan Chen
30db71d134 Clean up presigned URL implementation: remove dead fields, fix error handling
- Remove unused image_url from upload response and TS type
- Remove bare except in serve_image that masked config errors as 404s
- Add error state and broken-image placeholder in QuestionBubble

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:52:26 -04:00
Ryan Chen
167d014ca5 Use presigned S3 URLs for serving images instead of proxying bytes
Browser <img> tags can't attach JWT headers, causing 401s. The image
endpoint now returns a time-limited presigned S3 URL via authenticated
API call, which the frontend fetches and uses directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:52:26 -04:00
5 changed files with 51 additions and 22 deletions

View File

@@ -3,7 +3,7 @@ import json
import logging import logging
import uuid import uuid
from quart import Blueprint, Response, jsonify, make_response, request from quart import Blueprint, jsonify, make_response, request
from quart_jwt_extended import ( from quart_jwt_extended import (
get_jwt_identity, get_jwt_identity,
jwt_refresh_token_required, jwt_refresh_token_required,
@@ -12,6 +12,7 @@ from quart_jwt_extended import (
import blueprints.users.models import blueprints.users.models
from utils.image_process import analyze_user_image from utils.image_process import analyze_user_image
from utils.image_upload import ImageValidationError, process_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 get_image as s3_get_image
from utils.s3_client import upload_image as s3_upload_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) await s3_upload_image(processed_bytes, key, output_content_type)
return jsonify( return jsonify({"image_key": key})
{
"image_key": key,
"image_url": f"/api/conversation/image/{key}",
}
)
@conversation_blueprint.get("/image/<path:image_key>") @conversation_blueprint.get("/image/<path:image_key>")
@jwt_refresh_token_required @jwt_refresh_token_required
async def serve_image(image_key: str): async def serve_image(image_key: str):
try: url = await s3_presigned_url(image_key)
image_bytes, content_type = await s3_get_image(image_key) return jsonify({"url": url})
except Exception:
return jsonify({"error": "Image not found"}), 404
return Response(
image_bytes,
content_type=content_type,
headers={"Cache-Control": "private, max-age=3600"},
)
@conversation_blueprint.post("/stream-query") @conversation_blueprint.post("/stream-query")

View File

@@ -125,7 +125,7 @@ class ConversationService {
async uploadImage( async uploadImage(
file: File, file: File,
conversationId: string, conversationId: string,
): Promise<{ image_key: string; image_url: string }> { ): Promise<{ image_key: string }> {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
formData.append("conversation_id", conversationId); formData.append("conversation_id", conversationId);
@@ -147,8 +147,15 @@ class ConversationService {
return await response.json(); return await response.json();
} }
getImageUrl(imageKey: string): string { async getPresignedImageUrl(imageKey: string): Promise<string> {
return `/api/conversation/image/${imageKey}`; 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( async streamQuery(

View File

@@ -425,6 +425,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
handleQuestionSubmit={handleQuestionSubmit} handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode} setSimbaMode={setSimbaMode}
isLoading={isLoading} isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
/> />
</div> </div>
</footer> </footer>

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService"; import { conversationService } from "../api/conversationService";
@@ -7,6 +8,20 @@ type QuestionBubbleProps = {
}; };
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(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 ( return (
<div className="flex justify-end message-enter"> <div className="flex justify-end message-enter">
<div <div
@@ -17,9 +32,15 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
"shadow-sm shadow-leaf/10", "shadow-sm shadow-leaf/10",
)} )}
> >
{image_key && ( {imageError && (
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
<span>🖼</span>
<span>Image failed to load</span>
</div>
)}
{imageUrl && (
<img <img
src={conversationService.getImageUrl(image_key)} src={imageUrl}
alt="Uploaded image" alt="Uploaded image"
className="max-w-full rounded-xl mb-2" className="max-w-full rounded-xl mb-2"
/> />

View File

@@ -47,6 +47,16 @@ async def get_image(key: str) -> tuple[bytes, str]:
return body, content_type 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 def delete_image(key: str) -> None:
async with _get_client() as client: async with _get_client() as client:
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key) await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)