diff --git a/server/services/assistant_database.py b/server/services/assistant_database.py index 176768e..1545310 100644 --- a/server/services/assistant_database.py +++ b/server/services/assistant_database.py @@ -11,13 +11,17 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine, func from sqlalchemy.orm import declarative_base, relationship, sessionmaker logger = logging.getLogger(__name__) Base = declarative_base() +# Engine cache to avoid creating new engines for each request +# Key: project directory path (as posix string), Value: SQLAlchemy engine +_engine_cache: dict[str, object] = {} + def _utc_now() -> datetime: """Return current UTC time. Replacement for deprecated datetime.utcnow().""" @@ -56,13 +60,23 @@ def get_db_path(project_dir: Path) -> Path: def get_engine(project_dir: Path): - """Get or create a SQLAlchemy engine for a project's assistant database.""" - db_path = get_db_path(project_dir) - # Use as_posix() for cross-platform compatibility with SQLite connection strings - db_url = f"sqlite:///{db_path.as_posix()}" - engine = create_engine(db_url, echo=False) - Base.metadata.create_all(engine) - return engine + """Get or create a SQLAlchemy engine for a project's assistant database. + + Uses a cache to avoid creating new engines for each request, which improves + performance by reusing database connections. + """ + cache_key = project_dir.as_posix() + + if cache_key not in _engine_cache: + db_path = get_db_path(project_dir) + # Use as_posix() for cross-platform compatibility with SQLite connection strings + db_url = f"sqlite:///{db_path.as_posix()}" + engine = create_engine(db_url, echo=False) + Base.metadata.create_all(engine) + _engine_cache[cache_key] = engine + logger.debug(f"Created new database engine for {cache_key}") + + return _engine_cache[cache_key] def get_session(project_dir: Path): @@ -94,23 +108,44 @@ def create_conversation(project_dir: Path, project_name: str, title: Optional[st def get_conversations(project_dir: Path, project_name: str) -> list[dict]: - """Get all conversations for a project with message counts.""" + """Get all conversations for a project with message counts. + + Uses a subquery for message_count to avoid N+1 query problem. + """ session = get_session(project_dir) try: + # Subquery to count messages per conversation (avoids N+1 query) + message_count_subquery = ( + session.query( + ConversationMessage.conversation_id, + func.count(ConversationMessage.id).label("message_count") + ) + .group_by(ConversationMessage.conversation_id) + .subquery() + ) + + # Join conversation with message counts conversations = ( - session.query(Conversation) + session.query( + Conversation, + func.coalesce(message_count_subquery.c.message_count, 0).label("message_count") + ) + .outerjoin( + message_count_subquery, + Conversation.id == message_count_subquery.c.conversation_id + ) .filter(Conversation.project_name == project_name) .order_by(Conversation.updated_at.desc()) .all() ) return [ { - "id": c.id, - "project_name": c.project_name, - "title": c.title, - "created_at": c.created_at.isoformat() if c.created_at else None, - "updated_at": c.updated_at.isoformat() if c.updated_at else None, - "message_count": len(c.messages), + "id": c.Conversation.id, + "project_name": c.Conversation.project_name, + "title": c.Conversation.title, + "created_at": c.Conversation.created_at.isoformat() if c.Conversation.created_at else None, + "updated_at": c.Conversation.updated_at.isoformat() if c.Conversation.updated_at else None, + "message_count": c.message_count, } for c in conversations ] diff --git a/ui/src/components/AssistantChat.tsx b/ui/src/components/AssistantChat.tsx index b2a721e..5dcb303 100644 --- a/ui/src/components/AssistantChat.tsx +++ b/ui/src/components/AssistantChat.tsx @@ -78,22 +78,13 @@ export function AssistantChat({ // Start or resume the chat session when component mounts or conversationId changes useEffect(() => { - console.log('[AssistantChat] useEffect running:', { - conversationId, - isLoadingConversation, - lastRef: lastConversationIdRef.current, - hasStarted: hasStartedRef.current - }) - // Skip if we're loading conversation details if (isLoadingConversation) { - console.log('[AssistantChat] Skipping - loading conversation') return } // Only start if conversationId has actually changed if (lastConversationIdRef.current === conversationId && hasStartedRef.current) { - console.log('[AssistantChat] Skipping - same conversationId') return } @@ -101,23 +92,15 @@ export function AssistantChat({ const isSwitching = lastConversationIdRef.current !== undefined && lastConversationIdRef.current !== conversationId - console.log('[AssistantChat] Processing conversation change:', { - from: lastConversationIdRef.current, - to: conversationId, - isSwitching - }) - lastConversationIdRef.current = conversationId hasStartedRef.current = true // Clear existing messages when switching conversations if (isSwitching) { - console.log('[AssistantChat] Clearing messages for conversation switch') clearMessages() } // Start the session with the conversation ID (or null for new) - console.log('[AssistantChat] Starting session with conversationId:', conversationId) start(conversationId) }, [conversationId, isLoadingConversation, start, clearMessages]) @@ -129,7 +112,6 @@ export function AssistantChat({ // Handle selecting a conversation from history const handleSelectConversation = useCallback((id: number) => { - console.log('[AssistantChat] handleSelectConversation called with id:', id) setShowHistory(false) onSelectConversation?.(id) }, [onSelectConversation]) diff --git a/ui/src/components/AssistantPanel.tsx b/ui/src/components/AssistantPanel.tsx index 9ea7fad..5efe624 100644 --- a/ui/src/components/AssistantPanel.tsx +++ b/ui/src/components/AssistantPanel.tsx @@ -62,13 +62,6 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(), })) - console.log('[AssistantPanel] State:', { - conversationId, - isLoadingConversation, - conversationDetailId: conversationDetail?.id, - initialMessagesCount: initialMessages?.length ?? 0 - }) - // Persist conversation ID changes to localStorage useEffect(() => { setStoredConversationId(projectName, conversationId) @@ -86,7 +79,6 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP // Handle selecting a conversation from history const handleSelectConversation = useCallback((id: number) => { - console.log('[AssistantPanel] handleSelectConversation called with id:', id) setConversationId(id) }, []) diff --git a/ui/src/components/ChatMessage.tsx b/ui/src/components/ChatMessage.tsx index fd37073..a3417b5 100644 --- a/ui/src/components/ChatMessage.tsx +++ b/ui/src/components/ChatMessage.tsx @@ -5,6 +5,7 @@ * Supports user, assistant, and system messages with neobrutalism styling. */ +import { memo } from 'react' import { Bot, User, Info } from 'lucide-react' import type { ChatMessage as ChatMessageType } from '../lib/types' @@ -12,7 +13,10 @@ interface ChatMessageProps { message: ChatMessageType } -export function ChatMessage({ message }: ChatMessageProps) { +// Module-level regex to avoid recreating on each render +const BOLD_REGEX = /\*\*(.*?)\*\*/g + +export const ChatMessage = memo(function ChatMessage({ message }: ChatMessageProps) { const { role, content, attachments, timestamp, isStreaming } = message // Format timestamp @@ -112,13 +116,13 @@ export function ChatMessage({ message }: ChatMessageProps) { {content && (
{content.split('\n').map((line, i) => { - // Bold text - const boldRegex = /\*\*(.*?)\*\*/g + // Bold text - use module-level regex, reset lastIndex for each line + BOLD_REGEX.lastIndex = 0 const parts = [] let lastIndex = 0 let match - while ((match = boldRegex.exec(line)) !== null) { + while ((match = BOLD_REGEX.exec(line)) !== null) { if (match.index > lastIndex) { parts.push(line.slice(lastIndex, match.index)) } @@ -196,4 +200,4 @@ export function ChatMessage({ message }: ChatMessageProps) {
) -} +}) diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index 7511a8a..6e6a2a5 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -5,12 +5,13 @@ * Used to confirm destructive actions like deleting projects. */ +import type { ReactNode } from 'react' import { AlertTriangle, X } from 'lucide-react' interface ConfirmDialogProps { isOpen: boolean title: string - message: string + message: ReactNode confirmLabel?: string cancelLabel?: string variant?: 'danger' | 'warning' @@ -75,9 +76,9 @@ export function ConfirmDialog({ {/* Content */}
-

+

{message} -

+
{/* Actions */}
diff --git a/ui/src/components/ConversationHistory.tsx b/ui/src/components/ConversationHistory.tsx index 442b28f..db31c58 100644 --- a/ui/src/components/ConversationHistory.tsx +++ b/ui/src/components/ConversationHistory.tsx @@ -6,7 +6,7 @@ */ import { useState, useEffect } from 'react' -import { MessageSquare, Trash2, Loader2 } from 'lucide-react' +import { MessageSquare, Trash2, Loader2, AlertCircle } from 'lucide-react' import { useConversations, useDeleteConversation } from '../hooks/useConversations' import { ConfirmDialog } from './ConfirmDialog' import type { AssistantConversation } from '../lib/types' @@ -50,10 +50,18 @@ export function ConversationHistory({ onSelectConversation, }: ConversationHistoryProps) { const [conversationToDelete, setConversationToDelete] = useState(null) + const [deleteError, setDeleteError] = useState(null) const { data: conversations, isLoading } = useConversations(projectName) const deleteConversation = useDeleteConversation(projectName) + // Clear error when dropdown closes + useEffect(() => { + if (!isOpen) { + setDeleteError(null) + } + }, [isOpen]) + const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => { e.stopPropagation() setConversationToDelete(conversation) @@ -63,16 +71,18 @@ export function ConversationHistory({ if (!conversationToDelete) return try { + setDeleteError(null) await deleteConversation.mutateAsync(conversationToDelete.id) setConversationToDelete(null) - } catch (error) { - console.error('Failed to delete conversation:', error) - setConversationToDelete(null) + } catch { + // Keep dialog open and show error to user + setDeleteError('Failed to delete conversation. Please try again.') } } const handleCancelDelete = () => { setConversationToDelete(null) + setDeleteError(null) } const handleSelectConversation = (conversationId: number) => { @@ -183,7 +193,19 @@ export function ConversationHistory({ +

{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}

+
+ + {deleteError} +
+
+ ) : ( + `Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.` + ) + } confirmLabel="Delete" cancelLabel="Cancel" variant="danger"