Merge pull request 'Refactor frontend to hook-based architecture' (#33) from refactor/frontend-hooks into main
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
@@ -1,48 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
import { ChatScreen } from "./components/ChatScreen";
|
import { ChatScreen } from "./components/ChatScreen";
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
import { conversationService } from "./api/conversationService";
|
import { useAuthCheck } from "./hooks/useAuthCheck";
|
||||||
import catIcon from "./assets/cat.png";
|
import catIcon from "./assets/cat.png";
|
||||||
|
|
||||||
const AppContainer = () => {
|
const AppContainer = () => {
|
||||||
const [isAuthenticated, setAuthenticated] = useState<boolean>(false);
|
const { isAuthenticated, isChecking, isAdmin, setAuthenticated } = useAuthCheck();
|
||||||
const [isChecking, setIsChecking] = useState<boolean>(true);
|
|
||||||
|
|
||||||
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) {
|
if (isChecking) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
@@ -61,7 +26,7 @@ const AppContainer = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<ChatScreen setAuthenticated={setAuthenticated} />
|
<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen setAuthenticated={setAuthenticated} />
|
<LoginScreen setAuthenticated={setAuthenticated} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
|
||||||
import { userService, type AdminUserRecord } from "../api/userService";
|
import { userService, type AdminUserRecord } from "../api/userService";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
@@ -12,27 +12,19 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "./ui/table";
|
} from "./ui/table";
|
||||||
|
import { useAdminUsers } from "../hooks/useAdminUsers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminPanel = ({ onClose }: Props) => {
|
export const AdminPanel = ({ onClose }: Props) => {
|
||||||
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
const { users, loading, updateUser } = useAdminUsers();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
const [rowError, setRowError] = useState<Record<string, string>>({});
|
const [rowError, setRowError] = useState<Record<string, string>>({});
|
||||||
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
userService
|
|
||||||
.adminListUsers()
|
|
||||||
.then(setUsers)
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startEdit = (user: AdminUserRecord) => {
|
const startEdit = (user: AdminUserRecord) => {
|
||||||
setEditingId(user.id);
|
setEditingId(user.id);
|
||||||
setEditValue(user.whatsapp_number ?? "");
|
setEditValue(user.whatsapp_number ?? "");
|
||||||
@@ -49,8 +41,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
const updated = await userService.adminSetWhatsapp(userId, editValue);
|
||||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
updateUser(userId, () => updated);
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" }));
|
setRowSuccess((p) => ({ ...p, [userId]: "Saved" }));
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -65,10 +57,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
await userService.adminUnlinkWhatsapp(userId);
|
await userService.adminUnlinkWhatsapp(userId);
|
||||||
setUsers((p) =>
|
updateUser(userId, (u) => ({ ...u, whatsapp_number: null }));
|
||||||
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
|
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked" }));
|
||||||
);
|
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
|
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRowError((p) => ({
|
setRowError((p) => ({
|
||||||
@@ -82,8 +72,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
const updated = await userService.adminToggleEmail(userId);
|
const updated = await userService.adminToggleEmail(userId);
|
||||||
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
|
updateUser(userId, () => updated);
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled ✓" }));
|
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" }));
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRowError((p) => ({
|
setRowError((p) => ({
|
||||||
@@ -97,10 +87,8 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
setRowError((p) => ({ ...p, [userId]: "" }));
|
setRowError((p) => ({ ...p, [userId]: "" }));
|
||||||
try {
|
try {
|
||||||
await userService.adminDisableEmail(userId);
|
await userService.adminDisableEmail(userId);
|
||||||
setUsers((p) =>
|
updateUser(userId, (u) => ({ ...u, email_enabled: false, email_address: null }));
|
||||||
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
|
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled" }));
|
||||||
);
|
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
|
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRowError((p) => ({
|
setRowError((p) => ({
|
||||||
@@ -112,7 +100,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
|
|
||||||
const copyToClipboard = (text: string, userId: string) => {
|
const copyToClipboard = (text: string, userId: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
setRowSuccess((p) => ({ ...p, [userId]: "Copied ✓" }));
|
setRowSuccess((p) => ({ ...p, [userId]: "Copied" }));
|
||||||
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,7 +116,6 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
"border border-sand-light/60",
|
"border border-sand-light/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
|
||||||
@@ -146,7 +133,6 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
<div className="overflow-y-auto flex-1 rounded-b-3xl">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||||
@@ -155,7 +141,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
|
||||||
</div>
|
</div>
|
||||||
Loading users…
|
Loading users...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
@@ -204,7 +190,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
: "text-warm-gray/40 italic",
|
: "text-warm-gray/40 italic",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{user.whatsapp_number ?? "—"}
|
{user.whatsapp_number ?? "\u2014"}
|
||||||
</span>
|
</span>
|
||||||
{rowSuccess[user.id] && (
|
{rowSuccess[user.id] && (
|
||||||
<span className="text-xs text-leaf-dark">
|
<span className="text-xs text-leaf-dark">
|
||||||
@@ -235,7 +221,7 @@ export const AdminPanel = ({ onClose }: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-warm-gray/40 italic">—</span>
|
<span className="text-sm text-warm-gray/40 italic">\u2014</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ type AnswerBubbleProps = {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-start message-enter">
|
<div className="flex justify-start message-enter">
|
||||||
<div
|
<div
|
||||||
@@ -17,7 +18,6 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
|||||||
"overflow-hidden",
|
"overflow-hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* amber accent bar */}
|
|
||||||
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
|
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
|
||||||
|
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
@@ -36,4 +36,4 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -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 { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
|
||||||
import { conversationService } from "../api/conversationService";
|
|
||||||
import { userService } from "../api/userService";
|
|
||||||
import { QuestionBubble } from "./QuestionBubble";
|
import { QuestionBubble } from "./QuestionBubble";
|
||||||
import { AnswerBubble } from "./AnswerBubble";
|
import { AnswerBubble } from "./AnswerBubble";
|
||||||
import { ToolBubble } from "./ToolBubble";
|
import { ToolBubble } from "./ToolBubble";
|
||||||
@@ -9,205 +7,79 @@ import { MessageInput } from "./MessageInput";
|
|||||||
import { ConversationList } from "./ConversationList";
|
import { ConversationList } from "./ConversationList";
|
||||||
import { AdminPanel } from "./AdminPanel";
|
import { AdminPanel } from "./AdminPanel";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useConversations } from "../hooks/useConversations";
|
||||||
|
import { useChat } from "../hooks/useChat";
|
||||||
import catIcon from "../assets/cat.png";
|
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 = {
|
type ChatScreenProps = {
|
||||||
setAuthenticated: (isAuth: boolean) => void;
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOOL_MESSAGES: Record<string, string> = {
|
export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||||
simba_search: "🔍 Searching Simba's records...",
|
const [query, setQuery] = useState("");
|
||||||
web_search: "🌐 Searching the web...",
|
const [simbaMode, setSimbaMode] = useState(false);
|
||||||
get_current_date: "📅 Checking today's date...",
|
const [showConversations, setShowConversations] = useState(false);
|
||||||
ynab_budget_summary: "💰 Checking budget summary...",
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
ynab_search_transactions: "💳 Looking up transactions...",
|
const [showAdminPanel, setShowAdminPanel] = useState(false);
|
||||||
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<string>("");
|
|
||||||
const [simbaMode, setSimbaMode] = useState<boolean>(false);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
||||||
const [showConversations, setShowConversations] = useState<boolean>(false);
|
|
||||||
const [selectedConversation, setSelectedConversation] =
|
|
||||||
useState<Conversation | null>(null);
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
||||||
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
|
||||||
const [showAdminPanel, setShowAdminPanel] = useState<boolean>(false);
|
|
||||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isMountedRef = useRef<boolean>(true);
|
const isLoadingRef = useRef(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({
|
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) => {
|
const {
|
||||||
setShowConversations(false);
|
conversations,
|
||||||
setSelectedConversation(conversation);
|
selectedConversation,
|
||||||
const load = async () => {
|
selectConversation,
|
||||||
try {
|
createConversation,
|
||||||
const fetched = await conversationService.getConversation(conversation.id);
|
refreshConversations,
|
||||||
setMessages(
|
} = useConversations();
|
||||||
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 loadConversations = async () => {
|
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
|
||||||
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 handleCreateNewConversation = async () => {
|
const {
|
||||||
const newConv = await conversationService.createConversation();
|
messages,
|
||||||
await loadConversations();
|
setMessages,
|
||||||
setSelectedConversation({ title: newConv.name, id: newConv.id });
|
isLoading,
|
||||||
};
|
pendingImage,
|
||||||
|
setPendingImage,
|
||||||
|
sendMessage,
|
||||||
|
} = useChat({
|
||||||
|
selectedConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
onSessionExpired,
|
||||||
|
scrollToBottom,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
// Keep ref in sync for scrollToBottom behavior
|
||||||
loadConversations();
|
isLoadingRef.current = isLoading;
|
||||||
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSelectConversation = useCallback(
|
||||||
scrollToBottom();
|
async (conversation: { title: string; id: string }) => {
|
||||||
}, [messages]);
|
setShowConversations(false);
|
||||||
|
const loaded = await selectConversation(conversation);
|
||||||
|
setMessages(loaded);
|
||||||
|
},
|
||||||
|
[selectConversation, setMessages],
|
||||||
|
);
|
||||||
|
|
||||||
const handleQuestionSubmit = useCallback(async () => {
|
const handleCreateNewConversation = useCallback(async () => {
|
||||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
await createConversation();
|
||||||
|
setMessages([]);
|
||||||
|
}, [createConversation, setMessages]);
|
||||||
|
|
||||||
let activeConversation = selectedConversation;
|
const handleQuestionSubmit = useCallback(() => {
|
||||||
if (!activeConversation) {
|
sendMessage(query, simbaMode);
|
||||||
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);
|
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setPendingImage(null);
|
}, [query, simbaMode, sendMessage]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setQuery(event.target.value);
|
setQuery(event.target.value);
|
||||||
@@ -221,8 +93,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
}
|
}
|
||||||
}, [handleQuestionSubmit]);
|
}, [handleQuestionSubmit]);
|
||||||
|
|
||||||
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
|
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
|
||||||
const handleClearImage = useCallback(() => setPendingImage(null), []);
|
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem("access_token");
|
||||||
@@ -232,7 +104,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
|
||||||
{/* ── Desktop Sidebar ─────────────────────────────── */}
|
{/* Desktop Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden md:flex md:flex-col",
|
"hidden md:flex md:flex-col",
|
||||||
@@ -241,7 +113,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? (
|
{sidebarCollapsed ? (
|
||||||
/* Collapsed state */
|
|
||||||
<div className="flex flex-col items-center py-4 gap-4 h-full">
|
<div className="flex flex-col items-center py-4 gap-4 h-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarCollapsed(false)}
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
@@ -256,9 +127,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Expanded state */
|
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
@@ -277,7 +146,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversations */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
@@ -287,7 +155,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
@@ -310,12 +177,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Admin Panel modal */}
|
|
||||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||||
|
|
||||||
{/* ── Main chat area ──────────────────────────────── */}
|
|
||||||
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||||
{/* Mobile header */}
|
|
||||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||||
@@ -343,9 +207,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
/* ── Empty / homepage state ── */
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
||||||
{/* Mobile conversation drawer */}
|
|
||||||
{showConversations && (
|
{showConversations && (
|
||||||
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
@@ -382,11 +244,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ── Active chat state ── */
|
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||||
<div className="max-w-2xl mx-auto flex flex-col gap-3">
|
<div className="max-w-2xl mx-auto flex flex-col gap-3">
|
||||||
{/* Mobile conversation drawer */}
|
|
||||||
{showConversations && (
|
{showConversations && (
|
||||||
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
@@ -422,8 +282,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>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { conversationService } from "../api/conversationService";
|
|
||||||
|
|
||||||
type Conversation = {
|
type Conversation = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -23,32 +21,8 @@ export const ConversationList = ({
|
|||||||
selectedId,
|
selectedId,
|
||||||
variant = "dark",
|
variant = "dark",
|
||||||
}: ConversationProps) => {
|
}: 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 (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{/* New thread button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onCreateNewConversation}
|
onClick={onCreateNewConversation}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -63,8 +37,7 @@ export const ConversationList = ({
|
|||||||
<span>New thread</span>
|
<span>New thread</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Conversation items */}
|
{conversations.map((conv) => {
|
||||||
{items.map((conv) => {
|
|
||||||
const isActive = conv.id === selectedId;
|
const isActive = conv.id === selectedId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,66 +1,19 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { userService } from "../api/userService";
|
|
||||||
import { oidcService } from "../api/oidcService";
|
|
||||||
import catIcon from "../assets/cat.png";
|
import catIcon from "../assets/cat.png";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useOIDCAuth } from "../hooks/useOIDCAuth";
|
||||||
|
|
||||||
type LoginScreenProps = {
|
type LoginScreenProps = {
|
||||||
setAuthenticated: (isAuth: boolean) => void;
|
setAuthenticated: (isAuth: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
||||||
const [error, setError] = useState<string>("");
|
const { isChecking, isLoggingIn, error, handleLogin } = useOIDCAuth({
|
||||||
const [isChecking, setIsChecking] = useState<boolean>(true);
|
setAuthenticated,
|
||||||
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
|
});
|
||||||
|
|
||||||
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 handleOIDCLogin = 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isChecking || isLoggingIn) {
|
if (isChecking || isLoggingIn) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
|
||||||
{/* Subtle dot grid */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 pointer-events-none opacity-[0.035]"
|
className="fixed inset-0 pointer-events-none opacity-[0.035]"
|
||||||
style={{
|
style={{
|
||||||
@@ -85,7 +38,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
|
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
{/* Background dot texture */}
|
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 pointer-events-none opacity-[0.04]"
|
className="fixed inset-0 pointer-events-none opacity-[0.04]"
|
||||||
style={{
|
style={{
|
||||||
@@ -94,12 +46,10 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Decorative background blobs */}
|
|
||||||
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
|
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
|
||||||
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
|
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
<div className="relative w-full max-w-sm">
|
<div className="relative w-full max-w-sm">
|
||||||
{/* Branding */}
|
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-5">
|
||||||
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
|
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
|
||||||
@@ -120,7 +70,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-warm-white rounded-3xl border border-sand-light",
|
"bg-warm-white rounded-3xl border border-sand-light",
|
||||||
@@ -138,7 +87,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOIDCLogin}
|
onClick={handleLogin}
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
|
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
|
||||||
@@ -154,7 +103,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
|
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
|
||||||
✦ meow ✦
|
* meow *
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { conversationService } from "../api/conversationService";
|
import { usePresignedUrl } from "../hooks/usePresignedUrl";
|
||||||
|
|
||||||
type QuestionBubbleProps = {
|
type QuestionBubbleProps = {
|
||||||
text: string;
|
text: string;
|
||||||
image_key?: string | null;
|
image_key?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
const { imageUrl, imageError } = usePresignedUrl(image_key);
|
||||||
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">
|
||||||
@@ -34,7 +22,6 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
|||||||
>
|
>
|
||||||
{imageError && (
|
{imageError && (
|
||||||
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
|
<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>
|
<span>Image failed to load</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -49,4 +36,4 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
export const ToolBubble = ({ text }: { text: string }) => (
|
export const ToolBubble = React.memo(({ text }: { text: string }) => (
|
||||||
<div className="flex justify-center message-enter">
|
<div className="flex justify-center message-enter">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -12,4 +13,4 @@ export const ToolBubble = ({ text }: { text: string }) => (
|
|||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
));
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { userService, type AdminUserRecord } from "../api/userService";
|
||||||
|
|
||||||
|
export function useAdminUsers() {
|
||||||
|
const [users, setUsers] = useState<AdminUserRecord[]>([]);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<Conversation>;
|
||||||
|
refreshConversations: () => Promise<void>;
|
||||||
|
onSessionExpired: () => void;
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChat({
|
||||||
|
selectedConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
onSessionExpired,
|
||||||
|
scrollToBottom,
|
||||||
|
}: UseChatOptions) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Conversation[]>([]);
|
||||||
|
const [selectedConversation, setSelectedConversation] =
|
||||||
|
useState<Conversation | null>(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<Message[]> => {
|
||||||
|
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<Conversation> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { conversationService } from "../api/conversationService";
|
||||||
|
|
||||||
|
const urlCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export function usePresignedUrl(imageKey: string | null | undefined) {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user