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 && (
+
{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}
+