1 Commits

Author SHA1 Message Date
Ryan Chen
3671926430 Add redeploy Makefile target for quick pull-and-restart
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:10:10 -04:00
8 changed files with 43 additions and 91 deletions

View File

@@ -1,8 +1,11 @@
.PHONY: deploy build up down restart logs migrate migrate-new frontend test .PHONY: deploy redeploy build up down restart logs migrate migrate-new frontend test
# Build and deploy # Build and deploy
deploy: build up deploy: build up
redeploy:
git pull && $(MAKE) down && $(MAKE) up
build: build:
docker compose build raggr docker compose build raggr

3
app.py
View File

@@ -1,6 +1,5 @@
import logging import logging
import os import os
from datetime import timedelta
from dotenv import load_dotenv from dotenv import load_dotenv
from quart import Quart, jsonify, render_template, request, send_from_directory from quart import Quart, jsonify, render_template, request, send_from_directory
@@ -39,8 +38,6 @@ app = Quart(
) )
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY") app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY")
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
jwt = JWTManager(app) jwt = JWTManager(app)

View File

@@ -3,7 +3,7 @@ import json
import logging import logging
import uuid import uuid
from quart import Blueprint, jsonify, make_response, request from quart import Blueprint, Response, 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,7 +12,6 @@ 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
@@ -123,14 +122,27 @@ 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({"image_key": key}) return jsonify(
{
"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):
url = await s3_presigned_url(image_key) try:
return jsonify({"url": url}) 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"},
)
@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 }> { ): Promise<{ image_key: string; image_url: 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,15 +147,8 @@ class ConversationService {
return await response.json(); return await response.json();
} }
async getPresignedImageUrl(imageKey: string): Promise<string> { getImageUrl(imageKey: string): string {
const response = await userService.fetchWithRefreshToken( return `/api/conversation/image/${imageKey}`;
`${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

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react"; import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { conversationService } from "../api/conversationService"; import { conversationService } from "../api/conversationService";
import { userService } from "../api/userService"; import { userService } from "../api/userService";
@@ -63,13 +63,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = useCallback(() => { const scrollToBottom = () => {
requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
messagesEndRef.current?.scrollIntoView({ };
behavior: isLoading ? "instant" : "smooth",
});
});
}, [isLoading]);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@@ -134,7 +130,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
load(); load();
}, [selectedConversation?.id]); }, [selectedConversation?.id]);
const handleQuestionSubmit = useCallback(async () => { const handleQuestionSubmit = async () => {
if ((!query.trim() && !pendingImage) || isLoading) return; if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation; let activeConversation = selectedConversation;
@@ -218,22 +214,19 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
if (isMountedRef.current) setIsLoading(false); if (isMountedRef.current) setIsLoading(false);
abortControllerRef.current = null; abortControllerRef.current = null;
} }
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]); };
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(event.target.value); setQuery(event.target.value);
}, []); };
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>; const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
if (kev.key === "Enter" && !kev.shiftKey) { if (kev.key === "Enter" && !kev.shiftKey) {
kev.preventDefault(); kev.preventDefault();
handleQuestionSubmit(); handleQuestionSubmit();
} }
}, [handleQuestionSubmit]); };
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
const handleClearImage = useCallback(() => setPendingImage(null), []);
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
@@ -387,8 +380,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode} setSimbaMode={setSimbaMode}
isLoading={isLoading} isLoading={isLoading}
pendingImage={pendingImage} pendingImage={pendingImage}
onImageSelect={handleImageSelect} onImageSelect={(file) => setPendingImage(file)}
onClearImage={handleClearImage} onClearImage={() => setPendingImage(null)}
/> />
</div> </div>
</div> </div>
@@ -423,7 +416,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</div> </div>
</div> </div>
<footer className="border-t border-sand-light/40 bg-cream"> <footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
<div className="max-w-2xl mx-auto px-4 py-3"> <div className="max-w-2xl mx-auto px-4 py-3">
<MessageInput <MessageInput
query={query} query={query}
@@ -432,9 +425,6 @@ 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,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import { useRef, useState } from "react";
import { ArrowUp, ImagePlus, X } from "lucide-react"; import { ArrowUp, ImagePlus, X } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
@@ -15,7 +15,7 @@ type MessageInputProps = {
onClearImage: () => void; onClearImage: () => void;
}; };
export const MessageInput = React.memo(({ export const MessageInput = ({
query, query,
handleKeyDown, handleKeyDown,
handleQueryChange, handleQueryChange,
@@ -29,18 +29,6 @@ export const MessageInput = React.memo(({
const [simbaMode, setLocalSimbaMode] = useState(false); const [simbaMode, setLocalSimbaMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); 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 toggleSimbaMode = () => {
const next = !simbaMode; const next = !simbaMode;
setLocalSimbaMode(next); setLocalSimbaMode(next);
@@ -71,7 +59,7 @@ export const MessageInput = React.memo(({
<div className="px-3 pt-3"> <div className="px-3 pt-3">
<div className="relative inline-block"> <div className="relative inline-block">
<img <img
src={previewUrl!} src={URL.createObjectURL(pendingImage)}
alt="Pending upload" alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand" className="h-20 rounded-lg object-cover border border-sand"
/> />
@@ -157,4 +145,4 @@ export const MessageInput = React.memo(({
</div> </div>
</div> </div>
); );
}); };

View File

@@ -1,4 +1,3 @@
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";
@@ -8,20 +7,6 @@ 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
@@ -32,15 +17,9 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
"shadow-sm shadow-leaf/10", "shadow-sm shadow-leaf/10",
)} )}
> >
{imageError && ( {image_key && (
<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={imageUrl} src={conversationService.getImageUrl(image_key)}
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,16 +47,6 @@ 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)