9 Commits

Author SHA1 Message Date
ryan
17c3a2f888 Merge pull request 'Add redeploy Makefile target' (#20) from feat/makefile-redeploy into main
Reviewed-on: #20
2026-04-05 10:13:01 -04:00
ryan
fa0f68e3b4 Merge pull request 'Fix OIDC login crash when groups claim is null' (#22) from fix/oidc-null-groups into main
Reviewed-on: #22
2026-04-05 10:12:55 -04:00
Ryan Chen
a6c698c6bd Fix OIDC login crash when groups claim is null
Use `claims.get("groups") or []` instead of `claims.get("groups", [])`
so that an explicit `null` value is coerced to an empty list, preventing
a ValueError on the non-nullable ldap_groups field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:12:12 -04:00
ryan
975a337af4 Merge pull request 'Fix mobile performance degradation during typing and after image upload' (#21) from fix/mobile-input-performance into main
Reviewed-on: #21
2026-04-05 06:59:39 -04:00
Ryan Chen
e644def141 Fix mobile performance degradation during typing and after image upload
Memoize blob URL creation to prevent leak on every keystroke, wrap
MessageInput in React.memo with stable useCallback props, remove
expensive backdrop-blur-sm from chat footer, and use instant scroll
during streaming to avoid queuing smooth scroll animations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 06:58:53 -04:00
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
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
4 changed files with 46 additions and 21 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

View File

@@ -35,7 +35,7 @@ class OIDCUserService:
claims.get("preferred_username") or claims.get("name") or user.username claims.get("preferred_username") or claims.get("name") or user.username
) )
# Update LDAP groups from claims # Update LDAP groups from claims
user.ldap_groups = claims.get("groups", []) user.ldap_groups = claims.get("groups") or []
await user.save() await user.save()
return user return user
@@ -48,7 +48,7 @@ class OIDCUserService:
user.oidc_subject = oidc_subject user.oidc_subject = oidc_subject
user.auth_provider = "oidc" user.auth_provider = "oidc"
user.password = None # Clear password user.password = None # Clear password
user.ldap_groups = claims.get("groups", []) user.ldap_groups = claims.get("groups") or []
await user.save() await user.save()
return user return user
@@ -61,7 +61,7 @@ class OIDCUserService:
) )
# Extract LDAP groups from claims # Extract LDAP groups from claims
groups = claims.get("groups", []) groups = claims.get("groups") or []
user = await User.create( user = await User.create(
id=uuid4(), id=uuid4(),

View File

@@ -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 { 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,9 +63,13 @@ 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 = () => { const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); requestAnimationFrame(() => {
}; messagesEndRef.current?.scrollIntoView({
behavior: isLoading ? "instant" : "smooth",
});
});
}, [isLoading]);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@@ -130,7 +134,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
load(); load();
}, [selectedConversation?.id]); }, [selectedConversation?.id]);
const handleQuestionSubmit = async () => { const handleQuestionSubmit = useCallback(async () => {
if ((!query.trim() && !pendingImage) || isLoading) return; if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation; let activeConversation = selectedConversation;
@@ -214,19 +218,22 @@ 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 = (event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(event.target.value); 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>; 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");
@@ -380,8 +387,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode} setSimbaMode={setSimbaMode}
isLoading={isLoading} isLoading={isLoading}
pendingImage={pendingImage} pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)} onImageSelect={handleImageSelect}
onClearImage={() => setPendingImage(null)} onClearImage={handleClearImage}
/> />
</div> </div>
</div> </div>
@@ -416,7 +423,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</div> </div>
</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"> <div className="max-w-2xl mx-auto px-4 py-3">
<MessageInput <MessageInput
query={query} query={query}
@@ -425,6 +432,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,4 +1,4 @@
import { useRef, useState } from "react"; import React, { useEffect, useMemo, 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 = ({ export const MessageInput = React.memo(({
query, query,
handleKeyDown, handleKeyDown,
handleQueryChange, handleQueryChange,
@@ -29,6 +29,18 @@ export const MessageInput = ({
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);
@@ -59,7 +71,7 @@ export const MessageInput = ({
<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={URL.createObjectURL(pendingImage)} src={previewUrl!}
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"
/> />
@@ -145,4 +157,4 @@ export const MessageInput = ({
</div> </div>
</div> </div>
); );
}; });