Compare commits
6 Commits
feat/makef
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e644def141 | ||
|
|
be600e78d6 | ||
|
|
b6576fb2fd | ||
|
|
bb3ef4fe95 | ||
|
|
30db71d134 | ||
|
|
167d014ca5 |
@@ -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/<path:image_key>")
|
||||
@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")
|
||||
|
||||
@@ -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<string> {
|
||||
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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import { userService } from "../api/userService";
|
||||
@@ -63,9 +63,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
const scrollToBottom = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: isLoading ? "instant" : "smooth",
|
||||
});
|
||||
});
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
@@ -130,7 +134,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
load();
|
||||
}, [selectedConversation?.id]);
|
||||
|
||||
const handleQuestionSubmit = async () => {
|
||||
const handleQuestionSubmit = useCallback(async () => {
|
||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
||||
|
||||
let activeConversation = selectedConversation;
|
||||
@@ -214,19 +218,22 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
if (isMountedRef.current) setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
|
||||
|
||||
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setQuery(event.target.value);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
|
||||
if (kev.key === "Enter" && !kev.shiftKey) {
|
||||
kev.preventDefault();
|
||||
handleQuestionSubmit();
|
||||
}
|
||||
};
|
||||
}, [handleQuestionSubmit]);
|
||||
|
||||
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
|
||||
const handleClearImage = useCallback(() => setPendingImage(null), []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("access_token");
|
||||
@@ -380,8 +387,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
setSimbaMode={setSimbaMode}
|
||||
isLoading={isLoading}
|
||||
pendingImage={pendingImage}
|
||||
onImageSelect={(file) => setPendingImage(file)}
|
||||
onClearImage={() => setPendingImage(null)}
|
||||
onImageSelect={handleImageSelect}
|
||||
onClearImage={handleClearImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,7 +423,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
|
||||
<footer className="border-t border-sand-light/40 bg-cream">
|
||||
<div className="max-w-2xl mx-auto px-4 py-3">
|
||||
<MessageInput
|
||||
query={query}
|
||||
@@ -425,6 +432,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
||||
handleQuestionSubmit={handleQuestionSubmit}
|
||||
setSimbaMode={setSimbaMode}
|
||||
isLoading={isLoading}
|
||||
pendingImage={pendingImage}
|
||||
onImageSelect={(file) => setPendingImage(file)}
|
||||
onClearImage={() => setPendingImage(null)}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowUp, ImagePlus, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
@@ -15,7 +15,7 @@ type MessageInputProps = {
|
||||
onClearImage: () => void;
|
||||
};
|
||||
|
||||
export const MessageInput = ({
|
||||
export const MessageInput = React.memo(({
|
||||
query,
|
||||
handleKeyDown,
|
||||
handleQueryChange,
|
||||
@@ -29,6 +29,18 @@ export const MessageInput = ({
|
||||
const [simbaMode, setLocalSimbaMode] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Create blob URL once per file, revoke on cleanup
|
||||
const previewUrl = useMemo(
|
||||
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
|
||||
[pendingImage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const toggleSimbaMode = () => {
|
||||
const next = !simbaMode;
|
||||
setLocalSimbaMode(next);
|
||||
@@ -59,7 +71,7 @@ export const MessageInput = ({
|
||||
<div className="px-3 pt-3">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={URL.createObjectURL(pendingImage)}
|
||||
src={previewUrl!}
|
||||
alt="Pending upload"
|
||||
className="h-20 rounded-lg object-cover border border-sand"
|
||||
/>
|
||||
@@ -145,4 +157,4 @@ export const MessageInput = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<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 (
|
||||
<div className="flex justify-end message-enter">
|
||||
<div
|
||||
@@ -17,9 +32,15 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
||||
"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
|
||||
src={conversationService.getImageUrl(image_key)}
|
||||
src={imageUrl}
|
||||
alt="Uploaded image"
|
||||
className="max-w-full rounded-xl mb-2"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user