Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ac0754ea7 |
@@ -1,4 +1,4 @@
|
|||||||
SIMBA_SYSTEM_PROMPT = """You are Simba, Ryan's helpful personal assistant. You're named after his orange cat. You have a warm, friendly personality with a light cat-themed touch, but your priority is always being genuinely useful — give thorough, detailed answers and think things through carefully. When asked about Simba the cat, you speak as him in first person. For everything else, you're just a great assistant who happens to have a cat's name.
|
SIMBA_SYSTEM_PROMPT = """You are a helpful cat assistant named Simba that understands veterinary terms. When there are questions to you specifically, they are referring to Simba the cat. Answer the user in as if you were a cat named Simba. Don't act too catlike. Be assertive.
|
||||||
|
|
||||||
SIMBA FACTS (as of January 2026):
|
SIMBA FACTS (as of January 2026):
|
||||||
- Name: Simba
|
- Name: Simba
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
conversations,
|
||||||
|
selectedConversation,
|
||||||
|
selectConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
} = useConversations();
|
||||||
|
|
||||||
|
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
isLoading,
|
||||||
|
pendingImage,
|
||||||
|
setPendingImage,
|
||||||
|
sendMessage,
|
||||||
|
} = useChat({
|
||||||
|
selectedConversation,
|
||||||
|
createConversation,
|
||||||
|
refreshConversations,
|
||||||
|
onSessionExpired,
|
||||||
|
scrollToBottom,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep ref in sync for scrollToBottom behavior
|
||||||
|
isLoadingRef.current = isLoading;
|
||||||
|
|
||||||
|
const handleSelectConversation = useCallback(
|
||||||
|
async (conversation: { title: string; id: string }) => {
|
||||||
setShowConversations(false);
|
setShowConversations(false);
|
||||||
setSelectedConversation(conversation);
|
const loaded = await selectConversation(conversation);
|
||||||
const load = async () => {
|
setMessages(loaded);
|
||||||
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 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 handleCreateNewConversation = async () => {
|
|
||||||
const newConv = await conversationService.createConversation();
|
|
||||||
await loadConversations();
|
|
||||||
setSelectedConversation({ title: newConv.name, id: newConv.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConversations();
|
|
||||||
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
const handleQuestionSubmit = useCallback(async () => {
|
|
||||||
if ((!query.trim() && !pendingImage) || isLoading) return;
|
|
||||||
|
|
||||||
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);
|
|
||||||
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,
|
[selectConversation, setMessages],
|
||||||
imageKey,
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
const handleCreateNewConversation = useCallback(async () => {
|
||||||
console.log("Request was aborted");
|
await createConversation();
|
||||||
} else {
|
setMessages([]);
|
||||||
console.error("Failed to send query:", error);
|
}, [createConversation, setMessages]);
|
||||||
if (error instanceof Error && error.message.includes("Session expired")) {
|
|
||||||
setAuthenticated(false);
|
const handleQuestionSubmit = useCallback(() => {
|
||||||
}
|
sendMessage(query, simbaMode);
|
||||||
}
|
setQuery("");
|
||||||
} finally {
|
}, [query, simbaMode, sendMessage]);
|
||||||
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