Compare commits

...

7 Commits

Author SHA1 Message Date
Ryan Chen 9629bfcef4 Fix embedding tokenizer mismatch with custom embedding server
Disable tiktoken pre-encoding for custom embedding servers. LangChain
was encoding text into OpenAI token IDs then sending them to llama-server
which has a different vocabulary, causing "invalid tokens" errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 23:42:23 -04:00
Ryan Chen b4097730ef Add per-chunk error logging and broaden text sanitizer
Indexes chunks one at a time with error logging to identify which
document/chunk causes embedding failures. Also strips Unicode surrogates
and replacement characters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 23:38:03 -04:00
Ryan Chen abb06b78e2 Sanitize document text before embedding to fix tokenizer errors
Strips null bytes, control characters, and excessive whitespace from
document content before sending to embedding models. Fixes 400 errors
from BERT-based tokenizers (e.g. nomic-embed-text) on PDF-extracted text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 23:35:25 -04:00
Ryan Chen 92171cbfb6 Support custom OpenAI-compatible embedding server with OpenAI fallback
Adds EMBEDDING_SERVER_URL and EMBEDDING_MODEL_NAME env vars, mirroring
the existing LLAMA_SERVER_URL pattern for LLM configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 23:24:54 -04:00
ryan 8e884b5e76 Merge pull request 'Refactor frontend to hook-based architecture' (#33) from refactor/frontend-hooks into main
Reviewed-on: #33
2026-04-24 09:12:47 -04:00
ryan ed973357e8 Merge pull request 'Improve Simba system prompt' (#31) from feat/improve-system-prompt into main
Reviewed-on: #31
2026-04-24 09:12:39 -04:00
Ryan Chen 4ac0754ea7 Refactor frontend to hook-based architecture
Extract logic from god components into custom hooks (useAuthCheck,
useConversations, useChat, usePresignedUrl, useAdminUsers, useOIDCAuth).
Eliminate unnecessary useEffects per React guidelines — scroll is now
imperative, isAdmin comes from useAuthCheck instead of a separate fetch.
ConversationList becomes a pure presentational component. Wrap bubble
components in React.memo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:11:57 -04:00
17 changed files with 549 additions and 374 deletions
+6
View File
@@ -19,6 +19,12 @@ BASE_URL=192.168.1.5:8000
LLAMA_SERVER_URL=http://192.168.1.213:8080/v1
LLAMA_MODEL_NAME=llama-3.1-8b-instruct
# Embedding Server Configuration
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
# Falls back to OpenAI embeddings if not set
EMBEDDING_SERVER_URL=http://192.168.1.7:8086/v1
EMBEDDING_MODEL_NAME=all-minilm
# OpenAI Configuration
OPENAI_API_KEY=your-openai-api-key
+46 -3
View File
@@ -1,6 +1,7 @@
import datetime
import logging
import os
import re
from dotenv import load_dotenv
from langchain_core.documents import Document
@@ -17,7 +18,18 @@ load_dotenv()
logger = logging.getLogger(__name__)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
_embedding_server_url = os.getenv("EMBEDDING_SERVER_URL")
_embedding_model = os.getenv("EMBEDDING_MODEL_NAME", "text-embedding-3-small")
if _embedding_server_url:
embeddings = OpenAIEmbeddings(
model=_embedding_model,
base_url=_embedding_server_url,
api_key="not-needed",
check_embedding_ctx_length=False,
)
else:
embeddings = OpenAIEmbeddings(model=_embedding_model)
# Convert Tortoise-style postgres:// URL to SQLAlchemy-style postgresql+psycopg://
_db_url = os.getenv(
@@ -103,13 +115,43 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]:
return documents
def _sanitize_text(text_content: str) -> str:
"""Strip non-printable and invalid characters that break embedding tokenizers."""
# Remove null bytes and control characters (keep newlines and tabs)
text_content = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", text_content)
# Remove Unicode surrogates and other problematic Unicode
text_content = re.sub(r"[\ud800-\udfff\ufffe\uffff]", "", text_content)
# Remove replacement character clusters
text_content = text_content.replace("\ufffd", "")
# Collapse excessive whitespace
text_content = re.sub(r" {3,}", " ", text_content)
return text_content.strip()
def _sanitize_documents(documents: list[Document]) -> list[Document]:
"""Sanitize page_content of all documents for embedding compatibility."""
for doc in documents:
doc.page_content = _sanitize_text(doc.page_content)
return [doc for doc in documents if doc.page_content]
async def index_documents():
"""Index Paperless-NGX documents into vector store."""
documents = await fetch_documents_from_paperless_ngx()
splits = text_splitter.split_documents(documents)
splits = _sanitize_documents(splits)
logger.info(f"Indexing {len(splits)} chunks from {len(documents)} documents")
vector_store = _get_vector_store()
await vector_store.aadd_documents(documents=splits)
for i, split in enumerate(splits):
try:
await vector_store.aadd_documents(documents=[split])
except Exception as e:
logger.error(
f"Failed to embed chunk {i} from {split.metadata.get('filename', 'unknown')}: {e}"
)
logger.debug(f"Chunk content preview: {split.page_content[:200]!r}")
raise
async def fetch_obsidian_documents() -> list[Document]:
@@ -168,8 +210,9 @@ async def index_obsidian_documents():
# Delete existing obsidian chunks
delete_documents_by_metadata("source", "obsidian")
# Split and index documents
# Split, sanitize, and index documents
splits = text_splitter.split_documents(documents)
splits = _sanitize_documents(splits)
vector_store = _get_vector_store()
await vector_store.aadd_documents(documents=splits)
+2
View File
@@ -31,6 +31,8 @@ services:
- BASE_URL=${BASE_URL}
- OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- EMBEDDING_SERVER_URL=${EMBEDDING_SERVER_URL}
- EMBEDDING_MODEL_NAME=${EMBEDDING_MODEL_NAME}
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
- LLAMA_SERVER_URL=${LLAMA_SERVER_URL}
- LLAMA_MODEL_NAME=${LLAMA_MODEL_NAME}
+3 -38
View File
@@ -1,48 +1,13 @@
import { useState, useEffect } from "react";
import "./App.css";
import { AuthProvider } from "./contexts/AuthContext";
import { ChatScreen } from "./components/ChatScreen";
import { LoginScreen } from "./components/LoginScreen";
import { conversationService } from "./api/conversationService";
import { useAuthCheck } from "./hooks/useAuthCheck";
import catIcon from "./assets/cat.png";
const AppContainer = () => {
const [isAuthenticated, setAuthenticated] = useState<boolean>(false);
const [isChecking, setIsChecking] = useState<boolean>(true);
const { isAuthenticated, isChecking, isAdmin, setAuthenticated } = useAuthCheck();
useEffect(() => {
const checkAuth = async () => {
const accessToken = localStorage.getItem("access_token");
const refreshToken = localStorage.getItem("refresh_token");
// No tokens at all, not authenticated
if (!accessToken && !refreshToken) {
setIsChecking(false);
setAuthenticated(false);
return;
}
// Try to verify token by making a request
try {
await conversationService.getAllConversations();
// If successful, user is authenticated
setAuthenticated(true);
} catch (error) {
// Token is invalid or expired
console.error("Authentication check failed:", error);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setAuthenticated(false);
} finally {
setIsChecking(false);
}
};
checkAuth();
}, []);
// Show loading state while checking authentication
if (isChecking) {
return (
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
@@ -61,7 +26,7 @@ const AppContainer = () => {
return (
<>
{isAuthenticated ? (
<ChatScreen setAuthenticated={setAuthenticated} />
<ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />
) : (
<LoginScreen setAuthenticated={setAuthenticated} />
)}
+15 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
import { userService, type AdminUserRecord } from "../api/userService";
import { cn } from "../lib/utils";
@@ -12,27 +12,19 @@ import {
TableHeader,
TableRow,
} from "./ui/table";
import { useAdminUsers } from "../hooks/useAdminUsers";
type Props = {
onClose: () => void;
};
export const AdminPanel = ({ onClose }: Props) => {
const [users, setUsers] = useState<AdminUserRecord[]>([]);
const [loading, setLoading] = useState(true);
const { users, loading, updateUser } = useAdminUsers();
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [rowError, setRowError] = useState<Record<string, string>>({});
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
useEffect(() => {
userService
.adminListUsers()
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const startEdit = (user: AdminUserRecord) => {
setEditingId(user.id);
setEditValue(user.whatsapp_number ?? "");
@@ -49,8 +41,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
const updated = await userService.adminSetWhatsapp(userId, editValue);
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
setRowSuccess((p) => ({ ...p, [userId]: "Saved" }));
updateUser(userId, () => updated);
setRowSuccess((p) => ({ ...p, [userId]: "Saved" }));
setEditingId(null);
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
@@ -65,10 +57,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
await userService.adminUnlinkWhatsapp(userId);
setUsers((p) =>
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)),
);
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
updateUser(userId, (u) => ({ ...u, whatsapp_number: null }));
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
@@ -82,8 +72,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
const updated = await userService.adminToggleEmail(userId);
setUsers((p) => p.map((u) => (u.id === userId ? updated : u)));
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" }));
updateUser(userId, () => updated);
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
@@ -97,10 +87,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" }));
try {
await userService.adminDisableEmail(userId);
setUsers((p) =>
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)),
);
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
updateUser(userId, (u) => ({ ...u, email_enabled: false, email_address: null }));
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) {
setRowError((p) => ({
@@ -112,7 +100,7 @@ export const AdminPanel = ({ onClose }: Props) => {
const copyToClipboard = (text: string, userId: string) => {
navigator.clipboard.writeText(text);
setRowSuccess((p) => ({ ...p, [userId]: "Copied" }));
setRowSuccess((p) => ({ ...p, [userId]: "Copied" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
};
@@ -128,7 +116,6 @@ export const AdminPanel = ({ onClose }: Props) => {
"border border-sand-light/60",
)}
>
{/* Header */}
<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="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
@@ -146,7 +133,6 @@ export const AdminPanel = ({ onClose }: Props) => {
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 rounded-b-3xl">
{loading ? (
<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" />
</div>
Loading users
Loading users...
</div>
) : (
<Table>
@@ -204,7 +190,7 @@ export const AdminPanel = ({ onClose }: Props) => {
: "text-warm-gray/40 italic",
)}
>
{user.whatsapp_number ?? ""}
{user.whatsapp_number ?? "\u2014"}
</span>
{rowSuccess[user.id] && (
<span className="text-xs text-leaf-dark">
@@ -235,7 +221,7 @@ export const AdminPanel = ({ onClose }: Props) => {
</button>
</div>
) : (
<span className="text-sm text-warm-gray/40 italic"></span>
<span className="text-sm text-warm-gray/40 italic">\u2014</span>
)}
</div>
</TableCell>
@@ -1,3 +1,4 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import { cn } from "../lib/utils";
@@ -6,7 +7,7 @@ type AnswerBubbleProps = {
loading?: boolean;
};
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => {
return (
<div className="flex justify-start message-enter">
<div
@@ -17,7 +18,6 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
"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="px-4 py-3">
@@ -36,4 +36,4 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
</div>
</div>
);
};
});
+56 -196
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { useCallback, useState, useRef } from "react";
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { conversationService } from "../api/conversationService";
import { userService } from "../api/userService";
import { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble";
@@ -9,205 +7,79 @@ import { MessageInput } from "./MessageInput";
import { ConversationList } from "./ConversationList";
import { AdminPanel } from "./AdminPanel";
import { cn } from "../lib/utils";
import { useConversations } from "../hooks/useConversations";
import { useChat } from "../hooks/useChat";
import catIcon from "../assets/cat.png";
type Message = {
text: string;
speaker: "simba" | "user" | "tool";
image_key?: string | null;
};
type Conversation = {
title: string;
id: string;
};
type ChatScreenProps = {
setAuthenticated: (isAuth: boolean) => void;
isAdmin: boolean;
};
const TOOL_MESSAGES: Record<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...",
};
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);
export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
const [query, setQuery] = useState("");
const [simbaMode, setSimbaMode] = useState(false);
const [showConversations, setShowConversations] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showAdminPanel, setShowAdminPanel] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef<boolean>(true);
const abortControllerRef = useRef<AbortController | null>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const isLoadingRef = useRef(false);
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({
behavior: isLoading ? "instant" : "smooth",
behavior: isLoadingRef.current ? "instant" : "smooth",
});
});
}, [isLoading]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const handleSelectConversation = (conversation: Conversation) => {
setShowConversations(false);
setSelectedConversation(conversation);
const load = async () => {
try {
const fetched = await conversationService.getConversation(conversation.id);
setMessages(
fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })),
);
} catch (err) {
console.error("Failed to load messages:", err);
}
};
load();
};
const {
conversations,
selectedConversation,
selectConversation,
createConversation,
refreshConversations,
} = useConversations();
const loadConversations = async () => {
try {
const fetched = await conversationService.getAllConversations();
const parsed = fetched.map((c) => ({ id: c.id, title: c.name }));
setConversations(parsed);
} catch (err) {
console.error("Failed to load conversations:", err);
}
};
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
const handleCreateNewConversation = async () => {
const newConv = await conversationService.createConversation();
await loadConversations();
setSelectedConversation({ title: newConv.name, id: newConv.id });
};
const {
messages,
setMessages,
isLoading,
pendingImage,
setPendingImage,
sendMessage,
} = useChat({
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
scrollToBottom,
});
useEffect(() => {
loadConversations();
userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {});
}, []);
// Keep ref in sync for scrollToBottom behavior
isLoadingRef.current = isLoading;
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSelectConversation = useCallback(
async (conversation: { title: string; id: string }) => {
setShowConversations(false);
const loaded = await selectConversation(conversation);
setMessages(loaded);
},
[selectConversation, setMessages],
);
const handleQuestionSubmit = useCallback(async () => {
if ((!query.trim() && !pendingImage) || isLoading) return;
const handleCreateNewConversation = useCallback(async () => {
await createConversation();
setMessages([]);
}, [createConversation, setMessages]);
let activeConversation = selectedConversation;
if (!activeConversation) {
const newConv = await conversationService.createConversation();
activeConversation = { title: newConv.name, id: newConv.id };
setSelectedConversation(activeConversation);
setConversations((prev) => [activeConversation!, ...prev]);
}
// Capture pending image before clearing state
const imageFile = pendingImage;
const currMessages = messages.concat([{ text: query, speaker: "user" }]);
setMessages(currMessages);
const handleQuestionSubmit = useCallback(() => {
sendMessage(query, simbaMode);
setQuery("");
setPendingImage(null);
setIsLoading(true);
if (simbaMode) {
const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }]));
setIsLoading(false);
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
// Upload image first if present
let imageKey: string | undefined;
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
activeConversation.id,
);
imageKey = uploadResult.image_key;
// Update the user message with the image key
setMessages((prev) => {
const updated = [...prev];
// Find the last user message we just added
for (let i = updated.length - 1; i >= 0; i--) {
if (updated[i].speaker === "user") {
updated[i] = { ...updated[i], image_key: imageKey };
break;
}
}
return updated;
});
}
await conversationService.streamQuery(
query,
activeConversation.id,
(event) => {
if (!isMountedRef.current) return;
if (event.type === "tool_start") {
const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`;
setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }]));
} else if (event.type === "response") {
setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }]));
} else if (event.type === "error") {
console.error("Stream error:", event.message);
}
},
abortController.signal,
imageKey,
);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.log("Request was aborted");
} else {
console.error("Failed to send query:", error);
if (error instanceof Error && error.message.includes("Session expired")) {
setAuthenticated(false);
}
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
loadConversations();
}
abortControllerRef.current = null;
}
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
}, [query, simbaMode, sendMessage]);
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(event.target.value);
@@ -221,8 +93,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
}
}, [handleQuestionSubmit]);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
const handleClearImage = useCallback(() => setPendingImage(null), []);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
const handleLogout = () => {
localStorage.removeItem("access_token");
@@ -232,7 +104,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
return (
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
{/* ── Desktop Sidebar ─────────────────────────────── */}
{/* Desktop Sidebar */}
<aside
className={cn(
"hidden md:flex md:flex-col",
@@ -241,7 +113,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
)}
>
{sidebarCollapsed ? (
/* Collapsed state */
<div className="flex flex-col items-center py-4 gap-4 h-full">
<button
onClick={() => setSidebarCollapsed(false)}
@@ -256,9 +127,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
/>
</div>
) : (
/* Expanded state */
<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 gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -277,7 +146,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</button>
</div>
{/* Conversations */}
<div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList
conversations={conversations}
@@ -287,7 +155,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
/>
</div>
{/* Footer */}
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
{isAdmin && (
<button
@@ -310,12 +177,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
)}
</aside>
{/* Admin Panel modal */}
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{/* ── Main chat area ──────────────────────────────── */}
<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">
<div className="flex items-center gap-2">
<img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -343,9 +207,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</header>
{messages.length === 0 ? (
/* ── Empty / homepage state ── */
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
{/* Mobile conversation drawer */}
{showConversations && (
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
@@ -382,11 +244,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</div>
</div>
) : (
/* ── Active chat state ── */
<>
<div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-2xl mx-auto flex flex-col gap-3">
{/* Mobile conversation drawer */}
{showConversations && (
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList
@@ -422,8 +282,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode}
isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
/>
</div>
</footer>
@@ -1,7 +1,5 @@
import { useState, useEffect } from "react";
import { Plus } from "lucide-react";
import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService";
type Conversation = {
title: string;
@@ -23,32 +21,8 @@ export const ConversationList = ({
selectedId,
variant = "dark",
}: ConversationProps) => {
const [items, setItems] = useState(conversations);
useEffect(() => {
const load = async () => {
try {
let fetched = await conversationService.getAllConversations();
if (fetched.length === 0) {
await conversationService.createConversation();
fetched = await conversationService.getAllConversations();
}
setItems(fetched.map((c) => ({ id: c.id, title: c.name })));
} catch (err) {
console.error("Failed to load conversations:", err);
}
};
load();
}, []);
// Keep in sync when parent updates conversations
useEffect(() => {
setItems(conversations);
}, [conversations]);
return (
<div className="flex flex-col gap-1">
{/* New thread button */}
<button
onClick={onCreateNewConversation}
className={cn(
@@ -63,8 +37,7 @@ export const ConversationList = ({
<span>New thread</span>
</button>
{/* Conversation items */}
{items.map((conv) => {
{conversations.map((conv) => {
const isActive = conv.id === selectedId;
return (
<button
+6 -57
View File
@@ -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 { cn } from "../lib/utils";
import { useOIDCAuth } from "../hooks/useOIDCAuth";
type LoginScreenProps = {
setAuthenticated: (isAuth: boolean) => void;
};
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
const [error, setError] = useState<string>("");
const [isChecking, setIsChecking] = useState<boolean>(true);
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);
}
};
const { isChecking, isLoggingIn, error, handleLogin } = useOIDCAuth({
setAuthenticated,
});
if (isChecking || isLoggingIn) {
return (
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
{/* Subtle dot grid */}
<div
className="fixed inset-0 pointer-events-none opacity-[0.035]"
style={{
@@ -85,7 +38,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
return (
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
{/* Background dot texture */}
<div
className="fixed inset-0 pointer-events-none opacity-[0.04]"
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 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">
{/* Branding */}
<div className="flex flex-col items-center mb-8">
<div className="relative mb-5">
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
@@ -120,7 +70,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</p>
</div>
{/* Card */}
<div
className={cn(
"bg-warm-white rounded-3xl border border-sand-light",
@@ -138,7 +87,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</p>
<button
onClick={handleOIDCLogin}
onClick={handleLogin}
disabled={isLoggingIn}
className={cn(
"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>
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
meow
* meow *
</p>
</div>
</div>
@@ -1,26 +1,14 @@
import { useEffect, useState } from "react";
import React from "react";
import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService";
import { usePresignedUrl } from "../hooks/usePresignedUrl";
type QuestionBubbleProps = {
text: string;
image_key?: string | null;
};
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
useEffect(() => {
if (!image_key) return;
conversationService
.getPresignedImageUrl(image_key)
.then(setImageUrl)
.catch((err) => {
console.error("Failed to load image:", err);
setImageError(true);
});
}, [image_key]);
export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
const { imageUrl, imageError } = usePresignedUrl(image_key);
return (
<div className="flex justify-end message-enter">
@@ -34,7 +22,6 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
>
{imageError && (
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
<span>🖼</span>
<span>Image failed to load</span>
</div>
)}
@@ -49,4 +36,4 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
</div>
</div>
);
};
});
+3 -2
View File
@@ -1,6 +1,7 @@
import React from "react";
import { cn } from "../lib/utils";
export const ToolBubble = ({ text }: { text: string }) => (
export const ToolBubble = React.memo(({ text }: { text: string }) => (
<div className="flex justify-center message-enter">
<div
className={cn(
@@ -12,4 +13,4 @@ export const ToolBubble = ({ text }: { text: string }) => (
{text}
</div>
</div>
);
));
+21
View File
@@ -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 };
}
+37
View File
@@ -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 };
}
+183
View File
@@ -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,
};
}
+59
View File
@@ -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 };
}