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
deploy: build up
redeploy:
git pull && $(MAKE) down && $(MAKE) up
build:
docker compose build raggr

3
app.py
View File

@@ -1,6 +1,5 @@
import logging
import os
from datetime import timedelta
from dotenv import load_dotenv
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_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
jwt = JWTManager(app)

View File

@@ -3,7 +3,7 @@ import json
import logging
import uuid
from quart import Blueprint, jsonify, make_response, request
from quart import Blueprint, Response, jsonify, make_response, request
from quart_jwt_extended import (
get_jwt_identity,
jwt_refresh_token_required,
@@ -12,7 +12,6 @@ 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
@@ -123,14 +122,27 @@ async def upload_image():
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>")
@jwt_refresh_token_required
async def serve_image(image_key: str):
url = await s3_presigned_url(image_key)
return jsonify({"url": url})
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"},
)
@conversation_blueprint.post("/stream-query")

View File

@@ -125,7 +125,7 @@ class ConversationService {
async uploadImage(
file: File,
conversationId: string,
): Promise<{ image_key: string }> {
): Promise<{ image_key: string; image_url: string }> {
const formData = new FormData();
formData.append("file", file);
formData.append("conversation_id", conversationId);
@@ -147,15 +147,8 @@ class ConversationService {
return await response.json();
}
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;
getImageUrl(imageKey: string): string {
return `/api/conversation/image/${imageKey}`;
}
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 { conversationService } from "../api/conversationService";
import { userService } from "../api/userService";
@@ -63,13 +63,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const abortControllerRef = useRef<AbortController | null>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({
behavior: isLoading ? "instant" : "smooth",
});
});
}, [isLoading]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
isMountedRef.current = true;
@@ -134,7 +130,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
load();
}, [selectedConversation?.id]);
const handleQuestionSubmit = useCallback(async () => {
const handleQuestionSubmit = async () => {
if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation;
@@ -218,22 +214,19 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
if (isMountedRef.current) setIsLoading(false);
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);
}, []);
};
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const handleKeyDown = (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");
@@ -387,8 +380,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode}
isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
/>
</div>
</div>
@@ -423,7 +416,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</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">
<MessageInput
query={query}
@@ -432,9 +425,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode}
isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
/>
</div>
</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 { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea";
@@ -15,7 +15,7 @@ type MessageInputProps = {
onClearImage: () => void;
};
export const MessageInput = React.memo(({
export const MessageInput = ({
query,
handleKeyDown,
handleQueryChange,
@@ -29,18 +29,6 @@ export const MessageInput = React.memo(({
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);
@@ -71,7 +59,7 @@ export const MessageInput = React.memo(({
<div className="px-3 pt-3">
<div className="relative inline-block">
<img
src={previewUrl!}
src={URL.createObjectURL(pendingImage)}
alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand"
/>
@@ -157,4 +145,4 @@ export const MessageInput = React.memo(({
</div>
</div>
);
});
};

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService";
@@ -8,20 +7,6 @@ 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
@@ -32,15 +17,9 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
"shadow-sm shadow-leaf/10",
)}
>
{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 && (
{image_key && (
<img
src={imageUrl}
src={conversationService.getImageUrl(image_key)}
alt="Uploaded image"
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
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)