diff --git a/raggr-frontend/src/App.tsx b/raggr-frontend/src/App.tsx index 85339ad..4e12037 100644 --- a/raggr-frontend/src/App.tsx +++ b/raggr-frontend/src/App.tsx @@ -1,48 +1,13 @@ -import { useState, useEffect } from "react"; - import "./App.css"; import { AuthProvider } from "./contexts/AuthContext"; import { ChatScreen } from "./components/ChatScreen"; import { LoginScreen } from "./components/LoginScreen"; -import { conversationService } from "./api/conversationService"; +import { useAuthCheck } from "./hooks/useAuthCheck"; import catIcon from "./assets/cat.png"; const AppContainer = () => { - const [isAuthenticated, setAuthenticated] = useState(false); - const [isChecking, setIsChecking] = useState(true); + const { isAuthenticated, isChecking, isAdmin, setAuthenticated } = useAuthCheck(); - useEffect(() => { - const checkAuth = async () => { - const accessToken = localStorage.getItem("access_token"); - const refreshToken = localStorage.getItem("refresh_token"); - - // No tokens at all, not authenticated - if (!accessToken && !refreshToken) { - setIsChecking(false); - setAuthenticated(false); - return; - } - - // Try to verify token by making a request - try { - await conversationService.getAllConversations(); - // If successful, user is authenticated - setAuthenticated(true); - } catch (error) { - // Token is invalid or expired - console.error("Authentication check failed:", error); - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); - setAuthenticated(false); - } finally { - setIsChecking(false); - } - }; - - checkAuth(); - }, []); - - // Show loading state while checking authentication if (isChecking) { return (
@@ -61,7 +26,7 @@ const AppContainer = () => { return ( <> {isAuthenticated ? ( - + ) : ( )} diff --git a/raggr-frontend/src/components/AdminPanel.tsx b/raggr-frontend/src/components/AdminPanel.tsx index 6b55853..189b057 100644 --- a/raggr-frontend/src/components/AdminPanel.tsx +++ b/raggr-frontend/src/components/AdminPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react"; import { userService, type AdminUserRecord } from "../api/userService"; import { cn } from "../lib/utils"; @@ -12,27 +12,19 @@ import { TableHeader, TableRow, } from "./ui/table"; +import { useAdminUsers } from "../hooks/useAdminUsers"; type Props = { onClose: () => void; }; export const AdminPanel = ({ onClose }: Props) => { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); + const { users, loading, updateUser } = useAdminUsers(); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); const [rowError, setRowError] = useState>({}); const [rowSuccess, setRowSuccess] = useState>({}); - useEffect(() => { - userService - .adminListUsers() - .then(setUsers) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - const startEdit = (user: AdminUserRecord) => { setEditingId(user.id); setEditValue(user.whatsapp_number ?? ""); @@ -49,8 +41,8 @@ export const AdminPanel = ({ onClose }: Props) => { setRowError((p) => ({ ...p, [userId]: "" })); try { const updated = await userService.adminSetWhatsapp(userId, editValue); - setUsers((p) => p.map((u) => (u.id === userId ? updated : u))); - setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" })); + updateUser(userId, () => updated); + setRowSuccess((p) => ({ ...p, [userId]: "Saved" })); setEditingId(null); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); } catch (err) { @@ -65,10 +57,8 @@ export const AdminPanel = ({ onClose }: Props) => { setRowError((p) => ({ ...p, [userId]: "" })); try { await userService.adminUnlinkWhatsapp(userId); - setUsers((p) => - p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)), - ); - setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" })); + updateUser(userId, (u) => ({ ...u, whatsapp_number: null })); + setRowSuccess((p) => ({ ...p, [userId]: "Unlinked" })); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); } catch (err) { setRowError((p) => ({ @@ -82,8 +72,8 @@ export const AdminPanel = ({ onClose }: Props) => { setRowError((p) => ({ ...p, [userId]: "" })); try { const updated = await userService.adminToggleEmail(userId); - setUsers((p) => p.map((u) => (u.id === userId ? updated : u))); - setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" })); + updateUser(userId, () => updated); + setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" })); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); } catch (err) { setRowError((p) => ({ @@ -97,10 +87,8 @@ export const AdminPanel = ({ onClose }: Props) => { setRowError((p) => ({ ...p, [userId]: "" })); try { await userService.adminDisableEmail(userId); - setUsers((p) => - p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)), - ); - setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" })); + updateUser(userId, (u) => ({ ...u, email_enabled: false, email_address: null })); + setRowSuccess((p) => ({ ...p, [userId]: "Email disabled" })); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); } catch (err) { setRowError((p) => ({ @@ -112,7 +100,7 @@ export const AdminPanel = ({ onClose }: Props) => { const copyToClipboard = (text: string, userId: string) => { navigator.clipboard.writeText(text); - setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" })); + setRowSuccess((p) => ({ ...p, [userId]: "Copied" })); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); }; @@ -128,7 +116,6 @@ export const AdminPanel = ({ onClose }: Props) => { "border border-sand-light/60", )} > - {/* Header */}
@@ -146,7 +133,6 @@ export const AdminPanel = ({ onClose }: Props) => {
- {/* Body */}
{loading ? (
@@ -155,7 +141,7 @@ export const AdminPanel = ({ onClose }: Props) => {
- Loading users… + Loading users...
) : ( @@ -204,7 +190,7 @@ export const AdminPanel = ({ onClose }: Props) => { : "text-warm-gray/40 italic", )} > - {user.whatsapp_number ?? "—"} + {user.whatsapp_number ?? "\u2014"} {rowSuccess[user.id] && ( @@ -235,7 +221,7 @@ export const AdminPanel = ({ onClose }: Props) => { ) : ( - + \u2014 )} diff --git a/raggr-frontend/src/components/AnswerBubble.tsx b/raggr-frontend/src/components/AnswerBubble.tsx index db4207e..e560ae2 100644 --- a/raggr-frontend/src/components/AnswerBubble.tsx +++ b/raggr-frontend/src/components/AnswerBubble.tsx @@ -1,3 +1,4 @@ +import React from "react"; import ReactMarkdown from "react-markdown"; import { cn } from "../lib/utils"; @@ -6,7 +7,7 @@ type AnswerBubbleProps = { loading?: boolean; }; -export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => { +export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => { return (
{ "overflow-hidden", )} > - {/* amber accent bar */}
@@ -36,4 +36,4 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
); -}; +}); diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index 8febc36..6970da8 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -1,7 +1,5 @@ -import { useCallback, useEffect, useState, useRef } from "react"; +import { useCallback, useState, useRef } from "react"; import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react"; -import { conversationService } from "../api/conversationService"; -import { userService } from "../api/userService"; import { QuestionBubble } from "./QuestionBubble"; import { AnswerBubble } from "./AnswerBubble"; import { ToolBubble } from "./ToolBubble"; @@ -9,205 +7,79 @@ import { MessageInput } from "./MessageInput"; import { ConversationList } from "./ConversationList"; import { AdminPanel } from "./AdminPanel"; import { cn } from "../lib/utils"; +import { useConversations } from "../hooks/useConversations"; +import { useChat } from "../hooks/useChat"; import catIcon from "../assets/cat.png"; -type Message = { - text: string; - speaker: "simba" | "user" | "tool"; - image_key?: string | null; -}; - -type Conversation = { - title: string; - id: string; -}; - type ChatScreenProps = { setAuthenticated: (isAuth: boolean) => void; + isAdmin: boolean; }; -const TOOL_MESSAGES: Record = { - simba_search: "🔍 Searching Simba's records...", - web_search: "🌐 Searching the web...", - get_current_date: "📅 Checking today's date...", - ynab_budget_summary: "💰 Checking budget summary...", - ynab_search_transactions: "💳 Looking up transactions...", - ynab_category_spending: "📊 Analyzing category spending...", - ynab_insights: "📈 Generating budget insights...", - obsidian_search_notes: "📝 Searching notes...", - obsidian_read_note: "📖 Reading note...", - obsidian_create_note: "✏️ Saving note...", - obsidian_create_task: "✅ Creating task...", - journal_get_today: "📔 Reading today's journal...", - journal_get_tasks: "📋 Getting tasks...", - journal_add_task: "➕ Adding task...", - journal_complete_task: "✔️ Completing task...", -}; - -export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { - const [query, setQuery] = useState(""); - const [simbaMode, setSimbaMode] = useState(false); - const [messages, setMessages] = useState([]); - const [conversations, setConversations] = useState([]); - const [showConversations, setShowConversations] = useState(false); - const [selectedConversation, setSelectedConversation] = - useState(null); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [showAdminPanel, setShowAdminPanel] = useState(false); - const [pendingImage, setPendingImage] = useState(null); +export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { + const [query, setQuery] = useState(""); + const [simbaMode, setSimbaMode] = useState(false); + const [showConversations, setShowConversations] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [showAdminPanel, setShowAdminPanel] = useState(false); const messagesEndRef = useRef(null); - const isMountedRef = useRef(true); - const abortControllerRef = useRef(null); - const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; + const isLoadingRef = useRef(false); const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ - behavior: isLoading ? "instant" : "smooth", + behavior: isLoadingRef.current ? "instant" : "smooth", }); }); - }, [isLoading]); - - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - abortControllerRef.current?.abort(); - }; }, []); - const handleSelectConversation = (conversation: Conversation) => { - setShowConversations(false); - setSelectedConversation(conversation); - const load = async () => { - try { - const fetched = await conversationService.getConversation(conversation.id); - setMessages( - fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })), - ); - } catch (err) { - console.error("Failed to load messages:", err); - } - }; - load(); - }; + const { + conversations, + selectedConversation, + selectConversation, + createConversation, + refreshConversations, + } = useConversations(); - const loadConversations = async () => { - try { - const fetched = await conversationService.getAllConversations(); - const parsed = fetched.map((c) => ({ id: c.id, title: c.name })); - setConversations(parsed); - } catch (err) { - console.error("Failed to load conversations:", err); - } - }; + const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]); - const handleCreateNewConversation = async () => { - const newConv = await conversationService.createConversation(); - await loadConversations(); - setSelectedConversation({ title: newConv.name, id: newConv.id }); - }; + const { + messages, + setMessages, + isLoading, + pendingImage, + setPendingImage, + sendMessage, + } = useChat({ + selectedConversation, + createConversation, + refreshConversations, + onSessionExpired, + scrollToBottom, + }); - useEffect(() => { - loadConversations(); - userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {}); - }, []); + // Keep ref in sync for scrollToBottom behavior + isLoadingRef.current = isLoading; - useEffect(() => { - scrollToBottom(); - }, [messages]); + const handleSelectConversation = useCallback( + async (conversation: { title: string; id: string }) => { + setShowConversations(false); + const loaded = await selectConversation(conversation); + setMessages(loaded); + }, + [selectConversation, setMessages], + ); - const handleQuestionSubmit = useCallback(async () => { - if ((!query.trim() && !pendingImage) || isLoading) return; + const handleCreateNewConversation = useCallback(async () => { + await createConversation(); + setMessages([]); + }, [createConversation, setMessages]); - let activeConversation = selectedConversation; - if (!activeConversation) { - const newConv = await conversationService.createConversation(); - activeConversation = { title: newConv.name, id: newConv.id }; - setSelectedConversation(activeConversation); - setConversations((prev) => [activeConversation!, ...prev]); - } - - // Capture pending image before clearing state - const imageFile = pendingImage; - - const currMessages = messages.concat([{ text: query, speaker: "user" }]); - setMessages(currMessages); + const handleQuestionSubmit = useCallback(() => { + sendMessage(query, simbaMode); setQuery(""); - setPendingImage(null); - setIsLoading(true); - - if (simbaMode) { - const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)]; - setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }])); - setIsLoading(false); - return; - } - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - try { - // Upload image first if present - let imageKey: string | undefined; - if (imageFile) { - const uploadResult = await conversationService.uploadImage( - imageFile, - activeConversation.id, - ); - imageKey = uploadResult.image_key; - - // Update the user message with the image key - setMessages((prev) => { - const updated = [...prev]; - // Find the last user message we just added - for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].speaker === "user") { - updated[i] = { ...updated[i], image_key: imageKey }; - break; - } - } - return updated; - }); - } - - await conversationService.streamQuery( - query, - activeConversation.id, - (event) => { - if (!isMountedRef.current) return; - if (event.type === "tool_start") { - const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`; - setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }])); - } else if (event.type === "response") { - setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }])); - } else if (event.type === "error") { - console.error("Stream error:", event.message); - } - }, - abortController.signal, - imageKey, - ); - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - console.log("Request was aborted"); - } else { - console.error("Failed to send query:", error); - if (error instanceof Error && error.message.includes("Session expired")) { - setAuthenticated(false); - } - } - } finally { - if (isMountedRef.current) { - setIsLoading(false); - loadConversations(); - } - abortControllerRef.current = null; - } - }, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]); + }, [query, simbaMode, sendMessage]); const handleQueryChange = useCallback((event: React.ChangeEvent) => { setQuery(event.target.value); @@ -221,8 +93,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { } }, [handleQuestionSubmit]); - const handleImageSelect = useCallback((file: File) => setPendingImage(file), []); - const handleClearImage = useCallback(() => setPendingImage(null), []); + const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]); + const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]); const handleLogout = () => { localStorage.removeItem("access_token"); @@ -232,7 +104,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { return (
- {/* ── Desktop Sidebar ─────────────────────────────── */} + {/* Desktop Sidebar */} - {/* Admin Panel modal */} {showAdminPanel && setShowAdminPanel(false)} />} - {/* ── Main chat area ──────────────────────────────── */}
- {/* Mobile header */}
Simba @@ -343,9 +207,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
{messages.length === 0 ? ( - /* ── Empty / homepage state ── */
- {/* Mobile conversation drawer */} {showConversations && (
{
) : ( - /* ── Active chat state ── */ <>
- {/* Mobile conversation drawer */} {showConversations && (
{ setSimbaMode={setSimbaMode} isLoading={isLoading} pendingImage={pendingImage} - onImageSelect={(file) => setPendingImage(file)} - onClearImage={() => setPendingImage(null)} + onImageSelect={handleImageSelect} + onClearImage={handleClearImage} />
diff --git a/raggr-frontend/src/components/ConversationList.tsx b/raggr-frontend/src/components/ConversationList.tsx index 5553ac7..66684fc 100644 --- a/raggr-frontend/src/components/ConversationList.tsx +++ b/raggr-frontend/src/components/ConversationList.tsx @@ -1,7 +1,5 @@ -import { useState, useEffect } from "react"; import { Plus } from "lucide-react"; import { cn } from "../lib/utils"; -import { conversationService } from "../api/conversationService"; type Conversation = { title: string; @@ -23,32 +21,8 @@ export const ConversationList = ({ selectedId, variant = "dark", }: ConversationProps) => { - const [items, setItems] = useState(conversations); - - useEffect(() => { - const load = async () => { - try { - let fetched = await conversationService.getAllConversations(); - if (fetched.length === 0) { - await conversationService.createConversation(); - fetched = await conversationService.getAllConversations(); - } - setItems(fetched.map((c) => ({ id: c.id, title: c.name }))); - } catch (err) { - console.error("Failed to load conversations:", err); - } - }; - load(); - }, []); - - // Keep in sync when parent updates conversations - useEffect(() => { - setItems(conversations); - }, [conversations]); - return (
- {/* New thread button */} - {/* Conversation items */} - {items.map((conv) => { + {conversations.map((conv) => { const isActive = conv.id === selectedId; return (

- ✦ meow ✦ + * meow *

diff --git a/raggr-frontend/src/components/QuestionBubble.tsx b/raggr-frontend/src/components/QuestionBubble.tsx index a3678e4..fc90973 100644 --- a/raggr-frontend/src/components/QuestionBubble.tsx +++ b/raggr-frontend/src/components/QuestionBubble.tsx @@ -1,26 +1,14 @@ -import { useEffect, useState } from "react"; +import React from "react"; import { cn } from "../lib/utils"; -import { conversationService } from "../api/conversationService"; +import { usePresignedUrl } from "../hooks/usePresignedUrl"; type QuestionBubbleProps = { text: string; image_key?: string | null; }; -export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { - const [imageUrl, setImageUrl] = useState(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]); +export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => { + const { imageUrl, imageError } = usePresignedUrl(image_key); return (
@@ -34,7 +22,6 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { > {imageError && (
- 🖼️ Image failed to load
)} @@ -49,4 +36,4 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
); -}; +}); diff --git a/raggr-frontend/src/components/ToolBubble.tsx b/raggr-frontend/src/components/ToolBubble.tsx index c09dfce..22b0e85 100644 --- a/raggr-frontend/src/components/ToolBubble.tsx +++ b/raggr-frontend/src/components/ToolBubble.tsx @@ -1,6 +1,7 @@ +import React from "react"; import { cn } from "../lib/utils"; -export const ToolBubble = ({ text }: { text: string }) => ( +export const ToolBubble = React.memo(({ text }: { text: string }) => (
( {text}
-); +)); diff --git a/raggr-frontend/src/hooks/useAdminUsers.ts b/raggr-frontend/src/hooks/useAdminUsers.ts new file mode 100644 index 0000000..4a8e710 --- /dev/null +++ b/raggr-frontend/src/hooks/useAdminUsers.ts @@ -0,0 +1,21 @@ +import { useState, useEffect } from "react"; +import { userService, type AdminUserRecord } from "../api/userService"; + +export function useAdminUsers() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + userService + .adminListUsers() + .then(setUsers) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const updateUser = (userId: string, updater: (u: AdminUserRecord) => AdminUserRecord) => { + setUsers((prev) => prev.map((u) => (u.id === userId ? updater(u) : u))); + }; + + return { users, loading, updateUser }; +} diff --git a/raggr-frontend/src/hooks/useAuthCheck.ts b/raggr-frontend/src/hooks/useAuthCheck.ts new file mode 100644 index 0000000..e375eec --- /dev/null +++ b/raggr-frontend/src/hooks/useAuthCheck.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; +import { userService } from "../api/userService"; + +export function useAuthCheck() { + const [isAuthenticated, setAuthenticated] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const [isAdmin, setIsAdmin] = useState(false); + + useEffect(() => { + const checkAuth = async () => { + const accessToken = localStorage.getItem("access_token"); + const refreshToken = localStorage.getItem("refresh_token"); + + if (!accessToken && !refreshToken) { + setIsChecking(false); + setAuthenticated(false); + return; + } + + try { + const me = await userService.getMe(); + setAuthenticated(true); + setIsAdmin(me.is_admin); + } catch { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + setAuthenticated(false); + } finally { + setIsChecking(false); + } + }; + + checkAuth(); + }, []); + + return { isAuthenticated, isChecking, isAdmin, setAuthenticated }; +} diff --git a/raggr-frontend/src/hooks/useChat.ts b/raggr-frontend/src/hooks/useChat.ts new file mode 100644 index 0000000..389bfd8 --- /dev/null +++ b/raggr-frontend/src/hooks/useChat.ts @@ -0,0 +1,183 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { conversationService } from "../api/conversationService"; +import type { Conversation } from "./useConversations"; + +type Message = { + text: string; + speaker: "simba" | "user" | "tool"; + image_key?: string | null; +}; + +const TOOL_MESSAGES: Record = { + simba_search: "Searching Simba's records...", + web_search: "Searching the web...", + get_current_date: "Checking today's date...", + ynab_budget_summary: "Checking budget summary...", + ynab_search_transactions: "Looking up transactions...", + ynab_category_spending: "Analyzing category spending...", + ynab_insights: "Generating budget insights...", + obsidian_search_notes: "Searching notes...", + obsidian_read_note: "Reading note...", + obsidian_create_note: "Saving note...", + obsidian_create_task: "Creating task...", + journal_get_today: "Reading today's journal...", + journal_get_tasks: "Getting tasks...", + journal_add_task: "Adding task...", + journal_complete_task: "Completing task...", +}; + +const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; + +type UseChatOptions = { + selectedConversation: Conversation | null; + createConversation: () => Promise; + refreshConversations: () => Promise; + onSessionExpired: () => void; + scrollToBottom: () => void; +}; + +export function useChat({ + selectedConversation, + createConversation, + refreshConversations, + onSessionExpired, + scrollToBottom, +}: UseChatOptions) { + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [pendingImage, setPendingImage] = useState(null); + + const isMountedRef = useRef(true); + const abortControllerRef = useRef(null); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + abortControllerRef.current?.abort(); + }; + }, []); + + const updateMessages = useCallback( + (updater: Message[] | ((prev: Message[]) => Message[])) => { + setMessages(updater); + scrollToBottom(); + }, + [scrollToBottom], + ); + + const sendMessage = useCallback( + async (query: string, simbaMode: boolean) => { + if ((!query.trim() && !pendingImage) || isLoading) return; + + let activeConversation = selectedConversation; + let createdNew = false; + if (!activeConversation) { + activeConversation = await createConversation(); + createdNew = true; + } + + const imageFile = pendingImage; + + updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }])); + setPendingImage(null); + setIsLoading(true); + + if (simbaMode) { + const randomElement = + simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)]; + updateMessages((prev) => + prev.concat([{ text: randomElement, speaker: "simba" }]), + ); + setIsLoading(false); + return; + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + let imageKey: string | undefined; + if (imageFile) { + const uploadResult = await conversationService.uploadImage( + imageFile, + activeConversation.id, + ); + imageKey = uploadResult.image_key; + + updateMessages((prev) => { + const updated = [...prev]; + for (let i = updated.length - 1; i >= 0; i--) { + if (updated[i].speaker === "user") { + updated[i] = { ...updated[i], image_key: imageKey }; + break; + } + } + return updated; + }); + } + + await conversationService.streamQuery( + query, + activeConversation.id, + (event) => { + if (!isMountedRef.current) return; + if (event.type === "tool_start") { + const friendly = + TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`; + updateMessages((prev) => + prev.concat([{ text: friendly, speaker: "tool" }]), + ); + } else if (event.type === "response") { + updateMessages((prev) => + prev.concat([{ text: event.message, speaker: "simba" }]), + ); + } else if (event.type === "error") { + console.error("Stream error:", event.message); + } + }, + abortController.signal, + imageKey, + ); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + console.log("Request was aborted"); + } else { + console.error("Failed to send query:", error); + if ( + error instanceof Error && + error.message.includes("Session expired") + ) { + onSessionExpired(); + } + } + } finally { + if (isMountedRef.current) { + setIsLoading(false); + if (createdNew) { + refreshConversations(); + } + } + abortControllerRef.current = null; + } + }, + [ + pendingImage, + isLoading, + selectedConversation, + createConversation, + refreshConversations, + onSessionExpired, + updateMessages, + ], + ); + + return { + messages, + setMessages: updateMessages, + isLoading, + pendingImage, + setPendingImage, + sendMessage, + }; +} diff --git a/raggr-frontend/src/hooks/useConversations.ts b/raggr-frontend/src/hooks/useConversations.ts new file mode 100644 index 0000000..7605e2b --- /dev/null +++ b/raggr-frontend/src/hooks/useConversations.ts @@ -0,0 +1,69 @@ +import { useState, useCallback, useEffect } from "react"; +import { conversationService } from "../api/conversationService"; + +export type Conversation = { + title: string; + id: string; +}; + +type Message = { + text: string; + speaker: "simba" | "user" | "tool"; + image_key?: string | null; +}; + +export function useConversations() { + const [conversations, setConversations] = useState([]); + const [selectedConversation, setSelectedConversation] = + useState(null); + + const refreshConversations = useCallback(async () => { + try { + const fetched = await conversationService.getAllConversations(); + setConversations(fetched.map((c) => ({ id: c.id, title: c.name }))); + } catch (err) { + console.error("Failed to load conversations:", err); + } + }, []); + + useEffect(() => { + refreshConversations(); + }, [refreshConversations]); + + const selectConversation = useCallback( + async (conversation: Conversation): Promise => { + setSelectedConversation(conversation); + try { + const fetched = await conversationService.getConversation( + conversation.id, + ); + return fetched.messages.map((m) => ({ + text: m.text, + speaker: m.speaker, + image_key: m.image_key, + })); + } catch (err) { + console.error("Failed to load messages:", err); + return []; + } + }, + [], + ); + + const createConversation = useCallback(async (): Promise => { + const newConv = await conversationService.createConversation(); + const conversation = { title: newConv.name, id: newConv.id }; + setConversations((prev) => [conversation, ...prev]); + setSelectedConversation(conversation); + return conversation; + }, []); + + return { + conversations, + selectedConversation, + setSelectedConversation, + selectConversation, + createConversation, + refreshConversations, + }; +} diff --git a/raggr-frontend/src/hooks/useOIDCAuth.ts b/raggr-frontend/src/hooks/useOIDCAuth.ts new file mode 100644 index 0000000..19c5849 --- /dev/null +++ b/raggr-frontend/src/hooks/useOIDCAuth.ts @@ -0,0 +1,59 @@ +import { useState, useEffect } from "react"; +import { userService } from "../api/userService"; +import { oidcService } from "../api/oidcService"; + +type UseOIDCAuthOptions = { + setAuthenticated: (isAuth: boolean) => void; +}; + +export function useOIDCAuth({ setAuthenticated }: UseOIDCAuthOptions) { + const [isChecking, setIsChecking] = useState(true); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const initAuth = async () => { + const callbackParams = oidcService.getCallbackParamsFromURL(); + if (callbackParams) { + try { + setIsLoggingIn(true); + const result = await oidcService.handleCallback( + callbackParams.code, + callbackParams.state, + ); + localStorage.setItem("access_token", result.access_token); + localStorage.setItem("refresh_token", result.refresh_token); + oidcService.clearCallbackParams(); + setAuthenticated(true); + setIsChecking(false); + return; + } catch (err) { + console.error("OIDC callback error:", err); + setError("Login failed. Please try again."); + oidcService.clearCallbackParams(); + setIsLoggingIn(false); + setIsChecking(false); + return; + } + } + const isValid = await userService.validateToken(); + if (isValid) setAuthenticated(true); + setIsChecking(false); + }; + initAuth(); + }, [setAuthenticated]); + + const handleLogin = async () => { + try { + setIsLoggingIn(true); + setError(""); + const authUrl = await oidcService.initiateLogin(); + window.location.href = authUrl; + } catch { + setError("Failed to initiate login. Please try again."); + setIsLoggingIn(false); + } + }; + + return { isChecking, isLoggingIn, error, handleLogin }; +} diff --git a/raggr-frontend/src/hooks/usePresignedUrl.ts b/raggr-frontend/src/hooks/usePresignedUrl.ts new file mode 100644 index 0000000..58f1478 --- /dev/null +++ b/raggr-frontend/src/hooks/usePresignedUrl.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; +import { conversationService } from "../api/conversationService"; + +const urlCache = new Map(); + +export function usePresignedUrl(imageKey: string | null | undefined) { + const [imageUrl, setImageUrl] = useState( + imageKey ? (urlCache.get(imageKey) ?? null) : null, + ); + const [imageError, setImageError] = useState(false); + + useEffect(() => { + if (!imageKey) return; + + const cached = urlCache.get(imageKey); + if (cached) { + setImageUrl(cached); + return; + } + + conversationService + .getPresignedImageUrl(imageKey) + .then((url) => { + urlCache.set(imageKey, url); + setImageUrl(url); + }) + .catch((err) => { + console.error("Failed to load image:", err); + setImageError(true); + }); + }, [imageKey]); + + return { imageUrl, imageError }; +}