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:
parent
5dda8d66c2
commit
7e1eb60a4a
40 changed files with 163 additions and 31 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
BIN
backend/conversation_0f449f23-e92a-4c86-9cb7-c02b37f7aba3.mp3
Normal file
BIN
backend/conversation_0f449f23-e92a-4c86-9cb7-c02b37f7aba3.mp3
Normal file
Binary file not shown.
BIN
backend/conversation_1db6b0d1-5448-4a8c-8cf6-3fda65b6cb90.mp3
Normal file
BIN
backend/conversation_1db6b0d1-5448-4a8c-8cf6-3fda65b6cb90.mp3
Normal file
Binary file not shown.
BIN
backend/conversation_bb3c89d0-ab52-4570-83d1-ec6bc59f17ca.mp3
Normal file
BIN
backend/conversation_bb3c89d0-ab52-4570-83d1-ec6bc59f17ca.mp3
Normal file
Binary file not shown.
Binary file not shown.
|
|
@ -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")
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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")
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue