Fix critical bugs and improve features

- Add getSynthesis API endpoint (fixes console error)
- Fix message count display in chat sessions (SQL JOIN)
- Smart notebook deletion with share confirmation
- Auto-trigger synthesis when documents complete
- Regenerate synthesis on document add/delete
- Auto-retry pending background tasks on startup
- Backend session deduplication (10-second window)
- Disable React Query mutation retries
- Disable React Strict Mode
- Clean up old documentation files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DJP 2025-10-03 15:27:32 -04:00
parent 5dda8d66c2
commit 7e1eb60a4a
40 changed files with 163 additions and 31 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -64,3 +64,10 @@ async def root():
"docs": "/docs",
"health": "/api/health"
}
@app.on_event("startup")
async def startup_event():
"""Retry any pending background tasks on startup"""
from background_tasks import retry_pending_tasks
retry_pending_tasks()
print("✓ Background task system initialized")

View file

@ -53,11 +53,36 @@ async def get_chat_sessions(notebook_id: int, user_id: int = Query(1)):
@router.post("/{notebook_id}/sessions")
async def create_chat_session_endpoint(notebook_id: int, user_id: int = Query(1), title: Optional[str] = None):
"""Create a new chat session"""
"""Create a new chat session with deduplication"""
from database import get_db, ChatSession
from datetime import datetime, timedelta
notebook = get_notebook_by_id(notebook_id)
if not notebook:
raise HTTPException(status_code=404, detail="Notebook not found")
# AGGRESSIVE deduplication: Return most recent session if created within 10 seconds
# This handles mysterious duplicate requests from browser/network/system
db = get_db()
try:
recent = db.query(ChatSession).filter(
ChatSession.notebook_id == notebook_id,
ChatSession.user_id == user_id,
ChatSession.created_at > datetime.utcnow() - timedelta(seconds=10)
).order_by(ChatSession.created_at.desc()).first()
if recent:
print(f"🚫 Blocked duplicate - returning most recent session {recent.id} (age: {(datetime.utcnow() - recent.created_at).total_seconds():.1f}s)")
return {
"id": recent.id,
"title": recent.title,
"notebook_id": recent.notebook_id,
"is_shared": recent.is_shared,
"created_at": recent.created_at.isoformat()
}
finally:
db.close()
session = create_notebook_chat_session(user_id, notebook_id, title)
if not session:
raise HTTPException(status_code=500, detail="Failed to create chat session")

View file

@ -418,3 +418,24 @@ def get_notebook_processing_tasks(notebook_id: int) -> list:
finally:
db.close()
def retry_pending_tasks():
"""Retry all PENDING document processing tasks on startup"""
db = get_db()
try:
pending_tasks = db.query(BackgroundTask).filter(
BackgroundTask.task_type == 'document_processing',
BackgroundTask.status == TaskStatus.PENDING
).order_by(BackgroundTask.created_at).all()
if pending_tasks:
print(f"Found {len(pending_tasks)} pending document processing tasks - starting workers")
for task in pending_tasks:
thread = threading.Thread(target=run_background_task, args=(task.id,), daemon=True)
thread.start()
print(f" ✓ Started worker for task {task.id}")
else:
print("No pending tasks to retry")
finally:
db.close()

View file

@ -283,23 +283,30 @@ def create_notebook_chat_session(user_id: int, notebook_id: int, title: Optional
def get_user_private_chats(notebook_id: int, user_id: int) -> List[dict]:
"""Get user's private chat sessions for a notebook"""
from database import ChatSession
from database import ChatSession, ChatMessage
from sqlalchemy import func
db = get_db()
try:
sessions = db.query(ChatSession).filter(
sessions = db.query(
ChatSession,
func.count(ChatMessage.id).label('message_count')
).outerjoin(
ChatMessage, ChatSession.id == ChatMessage.session_id
).filter(
ChatSession.notebook_id == notebook_id,
ChatSession.user_id == user_id,
ChatSession.is_shared == False
).order_by(ChatSession.updated_at.desc()).all()
).group_by(ChatSession.id).order_by(ChatSession.updated_at.desc()).all()
# Return as dicts to avoid session issues
return [{
'id': s.id,
'title': s.title,
'user_id': s.user_id,
'is_shared': s.is_shared,
'created_at': s.created_at,
'updated_at': s.updated_at
'id': s.ChatSession.id,
'title': s.ChatSession.title,
'user_id': s.ChatSession.user_id,
'is_shared': s.ChatSession.is_shared,
'message_count': s.message_count,
'created_at': s.ChatSession.created_at,
'updated_at': s.ChatSession.updated_at
} for s in sessions]
finally:
db.close()
@ -307,23 +314,30 @@ def get_user_private_chats(notebook_id: int, user_id: int) -> List[dict]:
def get_shared_chats(notebook_id: int) -> List[dict]:
"""Get shared chat sessions for a notebook (visible to all collaborators)"""
from database import ChatSession, User
from database import ChatSession, ChatMessage, User
from sqlalchemy import func
db = get_db()
try:
sessions = db.query(ChatSession).filter(
sessions = db.query(
ChatSession,
func.count(ChatMessage.id).label('message_count')
).outerjoin(
ChatMessage, ChatSession.id == ChatMessage.session_id
).filter(
ChatSession.notebook_id == notebook_id,
ChatSession.is_shared == True
).order_by(ChatSession.updated_at.desc()).all()
).group_by(ChatSession.id).order_by(ChatSession.updated_at.desc()).all()
# Return as dicts with user info to avoid session issues
return [{
'id': s.id,
'title': s.title,
'user_id': s.user_id,
'username': s.user.username,
'is_shared': s.is_shared,
'created_at': s.created_at,
'updated_at': s.updated_at
'id': s.ChatSession.id,
'title': s.ChatSession.title,
'user_id': s.ChatSession.user_id,
'username': s.ChatSession.user.username,
'is_shared': s.ChatSession.is_shared,
'message_count': s.message_count,
'created_at': s.ChatSession.created_at,
'updated_at': s.ChatSession.updated_at
} for s in sessions]
finally:
db.close()

View file

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: false, // Disable to prevent double-mounting in dev that causes duplicate mutations
};
export default nextConfig;

View file

@ -95,14 +95,33 @@ export default function NotebookDetailPage() {
console.log('Synthesis SUCCESS:', data);
setSynthesis(data);
queryClient.invalidateQueries({ queryKey: ['synthesis', notebookId] });
alert('Cross-document analysis complete! Results saved and will persist.');
},
onError: (error: any) => {
console.error('Synthesis ERROR:', error);
alert(error.response?.data?.detail || 'Failed to generate synthesis. Make sure documents are processed.');
},
});
// Auto-trigger synthesis when all documents are processed
useEffect(() => {
if (!notebook || !tasks || synthesisMutation.isPending) return;
const documents = notebook.documents || [];
if (documents.length === 0) return;
// Check if all tasks are completed
const allTasksCompleted = tasks.every((task: any) =>
task.status === 'COMPLETED' || task.status === 'completed'
);
// Check if we have at least 2 documents for synthesis
const hasEnoughDocs = documents.length >= 2;
if (allTasksCompleted && hasEnoughDocs && !synthesis) {
console.log('All documents processed - auto-triggering synthesis');
synthesisMutation.mutate();
}
}, [notebook, tasks, synthesis, synthesisMutation.isPending]);
const podcastMutation = useMutation({
mutationFn: (data: { length: number; theme?: string }) =>
notebookAPI.generatePodcast(notebookId, data.length, data.theme),
@ -193,6 +212,7 @@ export default function NotebookDetailPage() {
setUploading(false);
queryClient.invalidateQueries({ queryKey: ['notebook', notebookId] });
queryClient.invalidateQueries({ queryKey: ['notebook-tasks', notebookId] });
queryClient.invalidateQueries({ queryKey: ['synthesis', notebookId] });
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
@ -760,8 +780,19 @@ function DocumentItem({
const deleteMutation = useMutation({
mutationFn: () => notebookAPI.removeDocument(notebookId, doc.id),
onSuccess: () => {
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['notebook', notebookId] });
queryClient.invalidateQueries({ queryKey: ['synthesis', notebookId] });
// Invalidate synthesis and trigger regeneration
try {
// Small delay to let the notebook query update
await new Promise(resolve => setTimeout(resolve, 1000));
const response = await notebookAPI.generateSynthesis(notebookId);
queryClient.invalidateQueries({ queryKey: ['synthesis', notebookId] });
} catch (error) {
console.error('Failed to regenerate synthesis after document deletion:', error);
}
},
});

View file

@ -44,10 +44,41 @@ export default function NotebooksPage() {
});
const deleteMutation = useMutation({
mutationFn: (id: number) => notebookAPI.delete(id),
mutationFn: async (id: number) => {
// First check if notebook has shares
const sharesResponse = await notebookAPI.getShares(id);
const shares = sharesResponse.data;
if (shares && shares.length > 0) {
const confirmDelete = confirm(
`This notebook is shared with ${shares.length} user(s). Deleting it will:\n` +
`- Remove all shares\n` +
`- Delete the notebook and all documents\n` +
`- Remove the LlamaIndex pipeline\n\n` +
`Are you sure you want to continue?`
);
if (!confirmDelete) {
throw new Error('Deletion cancelled');
}
// Remove all shares first
for (const share of shares) {
await notebookAPI.removeShare(id, share.id);
}
}
// Now delete the notebook
return notebookAPI.delete(id);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notebooks'] });
},
onError: (error: any) => {
if (error.message !== 'Deletion cancelled') {
alert(error.response?.data?.detail || 'Failed to delete notebook');
}
},
});
const handleCreate = (e: React.FormEvent) => {
@ -224,12 +255,9 @@ export default function NotebooksPage() {
<div className="flex items-start justify-between mb-3">
<BookOpen className="w-8 h-8 text-blue-600" />
<button
onClick={() => {
if (confirm('Are you sure you want to delete this notebook?')) {
deleteMutation.mutate(notebook.id);
}
}}
className="text-red-500 hover:text-red-700"
onClick={() => deleteMutation.mutate(notebook.id)}
disabled={deleteMutation.isPending}
className="text-red-500 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="w-5 h-5" />
</button>

View file

@ -67,6 +67,9 @@ export const notebookAPI = {
generateSynthesis: (id: number) =>
api.post<Synthesis>(`/notebooks/${id}/synthesis`),
getSynthesis: (id: number) =>
api.get<Synthesis>(`/notebooks/${id}/synthesis`),
// Podcast
generatePodcast: (id: number, target_length: number, custom_theme?: string) =>
api.post(`/notebooks/${id}/podcast`, { target_length, custom_theme }),

View file

@ -7,5 +7,8 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: true, // Refetch when returning to page
refetchOnMount: true, // Always refetch on mount
},
mutations: {
retry: false, // NEVER retry mutations to prevent duplicates
},
},
});