mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-31 14:43:35 +00:00
fix: address performance and code quality issues in conversation history
Performance improvements: - Fix N+1 query in get_conversations() using COUNT subquery instead of len(c.messages) which triggered lazy loading for each conversation - Add SQLAlchemy engine caching to avoid creating new database connections on every request - Add React.memo to ChatMessage component to prevent unnecessary re-renders during message streaming - Move BOLD_REGEX to module scope to avoid recreating on each render Code quality improvements: - Remove 10+ console.log debug statements from AssistantChat.tsx and AssistantPanel.tsx that were left from development - Add user feedback for delete errors in ConversationHistory - dialog now stays open and shows error message instead of silently failing - Update ConfirmDialog to accept ReactNode for message prop to support rich error content These changes address issues identified in the code review of PR #74 (conversation history feature). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,17 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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
|
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Base = declarative_base()
|
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:
|
def _utc_now() -> datetime:
|
||||||
"""Return current UTC time. Replacement for deprecated datetime.utcnow()."""
|
"""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):
|
def get_engine(project_dir: Path):
|
||||||
"""Get or create a SQLAlchemy engine for a project's assistant database."""
|
"""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
|
Uses a cache to avoid creating new engines for each request, which improves
|
||||||
db_url = f"sqlite:///{db_path.as_posix()}"
|
performance by reusing database connections.
|
||||||
engine = create_engine(db_url, echo=False)
|
"""
|
||||||
Base.metadata.create_all(engine)
|
cache_key = project_dir.as_posix()
|
||||||
return engine
|
|
||||||
|
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):
|
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]:
|
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)
|
session = get_session(project_dir)
|
||||||
try:
|
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 = (
|
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)
|
.filter(Conversation.project_name == project_name)
|
||||||
.order_by(Conversation.updated_at.desc())
|
.order_by(Conversation.updated_at.desc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": c.id,
|
"id": c.Conversation.id,
|
||||||
"project_name": c.project_name,
|
"project_name": c.Conversation.project_name,
|
||||||
"title": c.title,
|
"title": c.Conversation.title,
|
||||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
"created_at": c.Conversation.created_at.isoformat() if c.Conversation.created_at else None,
|
||||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
"updated_at": c.Conversation.updated_at.isoformat() if c.Conversation.updated_at else None,
|
||||||
"message_count": len(c.messages),
|
"message_count": c.message_count,
|
||||||
}
|
}
|
||||||
for c in conversations
|
for c in conversations
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -78,22 +78,13 @@ export function AssistantChat({
|
|||||||
|
|
||||||
// Start or resume the chat session when component mounts or conversationId changes
|
// Start or resume the chat session when component mounts or conversationId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[AssistantChat] useEffect running:', {
|
|
||||||
conversationId,
|
|
||||||
isLoadingConversation,
|
|
||||||
lastRef: lastConversationIdRef.current,
|
|
||||||
hasStarted: hasStartedRef.current
|
|
||||||
})
|
|
||||||
|
|
||||||
// Skip if we're loading conversation details
|
// Skip if we're loading conversation details
|
||||||
if (isLoadingConversation) {
|
if (isLoadingConversation) {
|
||||||
console.log('[AssistantChat] Skipping - loading conversation')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only start if conversationId has actually changed
|
// Only start if conversationId has actually changed
|
||||||
if (lastConversationIdRef.current === conversationId && hasStartedRef.current) {
|
if (lastConversationIdRef.current === conversationId && hasStartedRef.current) {
|
||||||
console.log('[AssistantChat] Skipping - same conversationId')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,23 +92,15 @@ export function AssistantChat({
|
|||||||
const isSwitching = lastConversationIdRef.current !== undefined &&
|
const isSwitching = lastConversationIdRef.current !== undefined &&
|
||||||
lastConversationIdRef.current !== conversationId
|
lastConversationIdRef.current !== conversationId
|
||||||
|
|
||||||
console.log('[AssistantChat] Processing conversation change:', {
|
|
||||||
from: lastConversationIdRef.current,
|
|
||||||
to: conversationId,
|
|
||||||
isSwitching
|
|
||||||
})
|
|
||||||
|
|
||||||
lastConversationIdRef.current = conversationId
|
lastConversationIdRef.current = conversationId
|
||||||
hasStartedRef.current = true
|
hasStartedRef.current = true
|
||||||
|
|
||||||
// Clear existing messages when switching conversations
|
// Clear existing messages when switching conversations
|
||||||
if (isSwitching) {
|
if (isSwitching) {
|
||||||
console.log('[AssistantChat] Clearing messages for conversation switch')
|
|
||||||
clearMessages()
|
clearMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the session with the conversation ID (or null for new)
|
// Start the session with the conversation ID (or null for new)
|
||||||
console.log('[AssistantChat] Starting session with conversationId:', conversationId)
|
|
||||||
start(conversationId)
|
start(conversationId)
|
||||||
}, [conversationId, isLoadingConversation, start, clearMessages])
|
}, [conversationId, isLoadingConversation, start, clearMessages])
|
||||||
|
|
||||||
@@ -129,7 +112,6 @@ export function AssistantChat({
|
|||||||
|
|
||||||
// Handle selecting a conversation from history
|
// Handle selecting a conversation from history
|
||||||
const handleSelectConversation = useCallback((id: number) => {
|
const handleSelectConversation = useCallback((id: number) => {
|
||||||
console.log('[AssistantChat] handleSelectConversation called with id:', id)
|
|
||||||
setShowHistory(false)
|
setShowHistory(false)
|
||||||
onSelectConversation?.(id)
|
onSelectConversation?.(id)
|
||||||
}, [onSelectConversation])
|
}, [onSelectConversation])
|
||||||
|
|||||||
@@ -62,13 +62,6 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
|
|||||||
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
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
|
// Persist conversation ID changes to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStoredConversationId(projectName, conversationId)
|
setStoredConversationId(projectName, conversationId)
|
||||||
@@ -86,7 +79,6 @@ export function AssistantPanel({ projectName, isOpen, onClose }: AssistantPanelP
|
|||||||
|
|
||||||
// Handle selecting a conversation from history
|
// Handle selecting a conversation from history
|
||||||
const handleSelectConversation = useCallback((id: number) => {
|
const handleSelectConversation = useCallback((id: number) => {
|
||||||
console.log('[AssistantPanel] handleSelectConversation called with id:', id)
|
|
||||||
setConversationId(id)
|
setConversationId(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Supports user, assistant, and system messages with neobrutalism styling.
|
* Supports user, assistant, and system messages with neobrutalism styling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { memo } from 'react'
|
||||||
import { Bot, User, Info } from 'lucide-react'
|
import { Bot, User, Info } from 'lucide-react'
|
||||||
import type { ChatMessage as ChatMessageType } from '../lib/types'
|
import type { ChatMessage as ChatMessageType } from '../lib/types'
|
||||||
|
|
||||||
@@ -12,7 +13,10 @@ interface ChatMessageProps {
|
|||||||
message: ChatMessageType
|
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
|
const { role, content, attachments, timestamp, isStreaming } = message
|
||||||
|
|
||||||
// Format timestamp
|
// Format timestamp
|
||||||
@@ -112,13 +116,13 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
|||||||
{content && (
|
{content && (
|
||||||
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
|
<div className={`whitespace-pre-wrap text-sm leading-relaxed ${config.textColor}`}>
|
||||||
{content.split('\n').map((line, i) => {
|
{content.split('\n').map((line, i) => {
|
||||||
// Bold text
|
// Bold text - use module-level regex, reset lastIndex for each line
|
||||||
const boldRegex = /\*\*(.*?)\*\*/g
|
BOLD_REGEX.lastIndex = 0
|
||||||
const parts = []
|
const parts = []
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
let match
|
let match
|
||||||
|
|
||||||
while ((match = boldRegex.exec(line)) !== null) {
|
while ((match = BOLD_REGEX.exec(line)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(line.slice(lastIndex, match.index))
|
parts.push(line.slice(lastIndex, match.index))
|
||||||
}
|
}
|
||||||
@@ -196,4 +200,4 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
* Used to confirm destructive actions like deleting projects.
|
* Used to confirm destructive actions like deleting projects.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { AlertTriangle, X } from 'lucide-react'
|
import { AlertTriangle, X } from 'lucide-react'
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: ReactNode
|
||||||
confirmLabel?: string
|
confirmLabel?: string
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
variant?: 'danger' | 'warning'
|
variant?: 'danger' | 'warning'
|
||||||
@@ -75,9 +76,9 @@ export function ConfirmDialog({
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<p className="text-[var(--color-neo-text-secondary)] mb-6">
|
<div className="text-[var(--color-neo-text-secondary)] mb-6">
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
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 { useConversations, useDeleteConversation } from '../hooks/useConversations'
|
||||||
import { ConfirmDialog } from './ConfirmDialog'
|
import { ConfirmDialog } from './ConfirmDialog'
|
||||||
import type { AssistantConversation } from '../lib/types'
|
import type { AssistantConversation } from '../lib/types'
|
||||||
@@ -50,10 +50,18 @@ export function ConversationHistory({
|
|||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
}: ConversationHistoryProps) {
|
}: ConversationHistoryProps) {
|
||||||
const [conversationToDelete, setConversationToDelete] = useState<AssistantConversation | null>(null)
|
const [conversationToDelete, setConversationToDelete] = useState<AssistantConversation | null>(null)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data: conversations, isLoading } = useConversations(projectName)
|
const { data: conversations, isLoading } = useConversations(projectName)
|
||||||
const deleteConversation = useDeleteConversation(projectName)
|
const deleteConversation = useDeleteConversation(projectName)
|
||||||
|
|
||||||
|
// Clear error when dropdown closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setDeleteError(null)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => {
|
const handleDeleteClick = (e: React.MouseEvent, conversation: AssistantConversation) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setConversationToDelete(conversation)
|
setConversationToDelete(conversation)
|
||||||
@@ -63,16 +71,18 @@ export function ConversationHistory({
|
|||||||
if (!conversationToDelete) return
|
if (!conversationToDelete) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setDeleteError(null)
|
||||||
await deleteConversation.mutateAsync(conversationToDelete.id)
|
await deleteConversation.mutateAsync(conversationToDelete.id)
|
||||||
setConversationToDelete(null)
|
setConversationToDelete(null)
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to delete conversation:', error)
|
// Keep dialog open and show error to user
|
||||||
setConversationToDelete(null)
|
setDeleteError('Failed to delete conversation. Please try again.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelDelete = () => {
|
const handleCancelDelete = () => {
|
||||||
setConversationToDelete(null)
|
setConversationToDelete(null)
|
||||||
|
setDeleteError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectConversation = (conversationId: number) => {
|
const handleSelectConversation = (conversationId: number) => {
|
||||||
@@ -183,7 +193,19 @@ export function ConversationHistory({
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={conversationToDelete !== null}
|
isOpen={conversationToDelete !== null}
|
||||||
title="Delete Conversation"
|
title="Delete Conversation"
|
||||||
message={`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}
|
message={
|
||||||
|
deleteError ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>{`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`}</p>
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-[var(--color-neo-danger)]/10 border border-[var(--color-neo-danger)] rounded text-sm text-[var(--color-neo-danger)]">
|
||||||
|
<AlertCircle size={16} className="flex-shrink-0" />
|
||||||
|
<span>{deleteError}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
`Are you sure you want to delete "${conversationToDelete?.title || 'this conversation'}"? This action cannot be undone.`
|
||||||
|
)
|
||||||
|
}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
cancelLabel="Cancel"
|
cancelLabel="Cancel"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
Reference in New Issue
Block a user