chore: clean up done
Browse files- Backend/app/api/v1/endpoints/notes.py +64 -2
- Frontend/src/App.tsx +53 -32
- Frontend/src/api/notesService.ts +19 -20
- Frontend/src/components/context/AuthContext.tsx +10 -2
- Frontend/src/pages/note.tsx +268 -149
Backend/app/api/v1/endpoints/notes.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Response
|
| 2 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
from app.models import User
|
| 4 |
from app.models.tables import PDFData
|
|
@@ -345,4 +345,66 @@ async def get_pdf_content(
|
|
| 345 |
raise HTTPException(status_code=404, detail="Note not found")
|
| 346 |
|
| 347 |
# Return raw bytes with PDF mime type so browser/frontend can render it
|
| 348 |
-
return Response(content=pdf.pdf_blob, media_type="application/pdf")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Response, Body
|
| 2 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
from app.models import User
|
| 4 |
from app.models.tables import PDFData
|
|
|
|
| 345 |
raise HTTPException(status_code=404, detail="Note not found")
|
| 346 |
|
| 347 |
# Return raw bytes with PDF mime type so browser/frontend can render it
|
| 348 |
+
return Response(content=pdf.pdf_blob, media_type="application/pdf")
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# -------------------------
|
| 352 |
+
# NEW: Delete Note
|
| 353 |
+
# -------------------------
|
| 354 |
+
@router.delete("/{note_id}")
|
| 355 |
+
async def delete_note(
|
| 356 |
+
note_id: int,
|
| 357 |
+
db: AsyncSession = Depends(get_db),
|
| 358 |
+
current_user: User = Depends(get_current_user),
|
| 359 |
+
collection: Collection = Depends(get_chroma_collection)
|
| 360 |
+
):
|
| 361 |
+
# 1. Check ownership
|
| 362 |
+
result = await db.execute(
|
| 363 |
+
select(PDFData).where(PDFData.id == note_id, PDFData.user_id == current_user.id)
|
| 364 |
+
)
|
| 365 |
+
note = result.scalar_one_or_none()
|
| 366 |
+
|
| 367 |
+
if not note:
|
| 368 |
+
raise HTTPException(status_code=404, detail="Note not found")
|
| 369 |
+
|
| 370 |
+
# 2. Delete from ChromaDB (using metadata filter)
|
| 371 |
+
try:
|
| 372 |
+
# This deletes all chunks where metadata field 'pdf_id' matches
|
| 373 |
+
await collection.delete(where={"pdf_id": note_id})
|
| 374 |
+
except Exception as e:
|
| 375 |
+
print(f"Error deleting from Chroma: {e}")
|
| 376 |
+
# Proceed to delete from DB even if Chroma fails to avoid sync issues
|
| 377 |
+
|
| 378 |
+
# 3. Delete from Database (Cascades to Sessions/Messages)
|
| 379 |
+
await db.delete(note)
|
| 380 |
+
await db.commit()
|
| 381 |
+
|
| 382 |
+
return {"status": "success", "message": "Note deleted"}
|
| 383 |
+
|
| 384 |
+
# -------------------------
|
| 385 |
+
# NEW: Rename Note
|
| 386 |
+
# -------------------------
|
| 387 |
+
@router.put("/{note_id}")
|
| 388 |
+
async def rename_note(
|
| 389 |
+
note_id: int,
|
| 390 |
+
new_filename: str = Body(..., embed=True), # Expects JSON: { "new_filename": "foo.pdf" }
|
| 391 |
+
db: AsyncSession = Depends(get_db),
|
| 392 |
+
current_user: User = Depends(get_current_user)
|
| 393 |
+
):
|
| 394 |
+
result = await db.execute(
|
| 395 |
+
select(PDFData).where(PDFData.id == note_id, PDFData.user_id == current_user.id)
|
| 396 |
+
)
|
| 397 |
+
note = result.scalar_one_or_none()
|
| 398 |
+
|
| 399 |
+
if not note:
|
| 400 |
+
raise HTTPException(status_code=404, detail="Note not found")
|
| 401 |
+
|
| 402 |
+
note.filename = new_filename
|
| 403 |
+
await db.commit()
|
| 404 |
+
await db.refresh(note)
|
| 405 |
+
|
| 406 |
+
return {
|
| 407 |
+
"id": note.id,
|
| 408 |
+
"filename": note.filename,
|
| 409 |
+
"created_at": note.created_at
|
| 410 |
+
}
|
Frontend/src/App.tsx
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
import React from "react";
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
import Home from "./pages/home";
|
| 5 |
import Dashboard from "./pages/dashboard";
|
|
@@ -10,15 +16,13 @@ import Sidebar from "./components/dashboard/Sidebar";
|
|
| 10 |
|
| 11 |
import { AuthProvider, useAuth } from "./components/context/AuthContext";
|
| 12 |
import ProtectedRoute from "./routes/ProtectedRoute";
|
|
|
|
| 13 |
|
|
|
|
| 14 |
const DashboardLayout = () => {
|
| 15 |
-
// 1. Retrieve both logout and username from the AuthContext
|
| 16 |
-
|
| 17 |
return (
|
| 18 |
<div className="flex h-screen bg-gray-100">
|
| 19 |
-
|
| 20 |
-
<Sidebar/>
|
| 21 |
-
|
| 22 |
<main className="flex-1 overflow-y-auto">
|
| 23 |
<Routes>
|
| 24 |
<Route path="/dashboard" element={<Dashboard />} />
|
|
@@ -32,36 +36,53 @@ const DashboardLayout = () => {
|
|
| 32 |
);
|
| 33 |
};
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
<Route path="/" element={<HomeWrapper />} />
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
element={
|
| 47 |
-
<ProtectedRoute>
|
| 48 |
-
<DashboardLayout />
|
| 49 |
-
</ProtectedRoute>
|
| 50 |
-
}
|
| 51 |
-
/>
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
};
|
| 66 |
|
| 67 |
-
export default App;
|
|
|
|
| 1 |
import React from "react";
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
BrowserRouter as Router,
|
| 5 |
+
Routes,
|
| 6 |
+
Route,
|
| 7 |
+
Navigate,
|
| 8 |
+
} from "react-router-dom";
|
| 9 |
|
| 10 |
import Home from "./pages/home";
|
| 11 |
import Dashboard from "./pages/dashboard";
|
|
|
|
| 16 |
|
| 17 |
import { AuthProvider, useAuth } from "./components/context/AuthContext";
|
| 18 |
import ProtectedRoute from "./routes/ProtectedRoute";
|
| 19 |
+
import { Loader2 } from "lucide-react";
|
| 20 |
|
| 21 |
+
// Layout for authenticated users
|
| 22 |
const DashboardLayout = () => {
|
|
|
|
|
|
|
| 23 |
return (
|
| 24 |
<div className="flex h-screen bg-gray-100">
|
| 25 |
+
<Sidebar />
|
|
|
|
|
|
|
| 26 |
<main className="flex-1 overflow-y-auto">
|
| 27 |
<Routes>
|
| 28 |
<Route path="/dashboard" element={<Dashboard />} />
|
|
|
|
| 36 |
);
|
| 37 |
};
|
| 38 |
|
| 39 |
+
// Wrapper for Home to handle "If logged in, go to dashboard" logic
|
| 40 |
+
const HomeWrapper = () => {
|
| 41 |
+
const { isAuthenticated, login } = useAuth();
|
| 42 |
+
if (isAuthenticated) return <Navigate to="/dashboard" replace />;
|
| 43 |
+
return <Home onLogin={login} />;
|
| 44 |
+
};
|
|
|
|
| 45 |
|
| 46 |
+
// Main Routing Logic extracted to use AuthContext
|
| 47 |
+
const AppRoutes = () => {
|
| 48 |
+
const { isLoading } = useAuth();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
// 1. Show a loading spinner while checking auth state
|
| 51 |
+
// This prevents the "flash" of redirecting to home/dashboard on reload
|
| 52 |
+
if (isLoading) {
|
| 53 |
+
return (
|
| 54 |
+
<div className="flex h-screen w-full items-center justify-center bg-gray-900 text-white">
|
| 55 |
+
<Loader2 className="h-10 w-10 animate-spin text-blue-500" />
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
}
|
| 59 |
|
| 60 |
+
return (
|
| 61 |
+
<Router>
|
| 62 |
+
<Routes>
|
| 63 |
+
{/* Public Home */}
|
| 64 |
+
<Route path="/" element={<HomeWrapper />} />
|
| 65 |
|
| 66 |
+
{/* Protected Dashboard Routes */}
|
| 67 |
+
<Route
|
| 68 |
+
path="/*"
|
| 69 |
+
element={
|
| 70 |
+
<ProtectedRoute>
|
| 71 |
+
<DashboardLayout />
|
| 72 |
+
</ProtectedRoute>
|
| 73 |
+
}
|
| 74 |
+
/>
|
| 75 |
+
</Routes>
|
| 76 |
+
</Router>
|
| 77 |
+
);
|
| 78 |
+
};
|
| 79 |
|
| 80 |
+
const App: React.FC = () => {
|
| 81 |
+
return (
|
| 82 |
+
<AuthProvider>
|
| 83 |
+
<AppRoutes />
|
| 84 |
+
</AuthProvider>
|
| 85 |
+
);
|
| 86 |
};
|
| 87 |
|
| 88 |
+
export default App;
|
Frontend/src/api/notesService.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
// Frontend/src/api/notesService.ts
|
| 2 |
import API from "./api"; // Your existing Axios instance
|
| 3 |
import { type AxiosResponse } from "axios";
|
| 4 |
|
|
@@ -30,7 +29,7 @@ export const fetchNotes = async (): Promise<Note[]> => {
|
|
| 30 |
// 2. Upload a new PDF
|
| 31 |
export const uploadNote = async (file: File): Promise<Note> => {
|
| 32 |
const formData = new FormData();
|
| 33 |
-
formData.append("file", file);
|
| 34 |
|
| 35 |
const response = await API.post("/notes/upload_notes", formData, {
|
| 36 |
headers: {
|
|
@@ -38,28 +37,31 @@ export const uploadNote = async (file: File): Promise<Note> => {
|
|
| 38 |
},
|
| 39 |
});
|
| 40 |
|
| 41 |
-
// The backend returns complex data, but we just need the basics for the list update
|
| 42 |
-
// Mapping the response to our Note interface structure locally if needed,
|
| 43 |
-
// or you can adjust the backend response.
|
| 44 |
-
// For now, we assume the backend returns the created doc info or we construct it.
|
| 45 |
return {
|
| 46 |
id: response.data.doc_id,
|
| 47 |
filename: response.data.filename,
|
| 48 |
-
created_at: new Date().toISOString(),
|
| 49 |
};
|
| 50 |
};
|
| 51 |
|
| 52 |
-
// 3.
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
export const getNoteContentUrl = (noteId: number): string => {
|
| 55 |
-
const token = localStorage.getItem("token");
|
| 56 |
-
// We append the token as a query param or handle auth differently for iframes.
|
| 57 |
-
// Since standard Bearer auth is hard with simple <iframe src="...">,
|
| 58 |
-
// we will fetch the blob via JS and create an ObjectURL in the component.
|
| 59 |
return `/api/v1/notes/${noteId}/content`;
|
| 60 |
};
|
| 61 |
|
| 62 |
-
//
|
| 63 |
export const fetchNoteBlob = async (noteId: number): Promise<Blob> => {
|
| 64 |
const response = await API.get(`/notes/${noteId}/content`, {
|
| 65 |
responseType: "blob",
|
|
@@ -67,7 +69,7 @@ export const fetchNoteBlob = async (noteId: number): Promise<Blob> => {
|
|
| 67 |
return response.data;
|
| 68 |
};
|
| 69 |
|
| 70 |
-
//
|
| 71 |
export const createChatSession = async (
|
| 72 |
pdfId: number,
|
| 73 |
name: string = "New Chat"
|
|
@@ -76,7 +78,7 @@ export const createChatSession = async (
|
|
| 76 |
return response.data;
|
| 77 |
};
|
| 78 |
|
| 79 |
-
//
|
| 80 |
export const fetchChatHistory = async (sessionId: string) => {
|
| 81 |
const response = await API.get(`/notes/history/${sessionId}`);
|
| 82 |
return response.data;
|
|
@@ -87,7 +89,7 @@ export const fetchSessions = async (pdfId: number): Promise<Session[]> => {
|
|
| 87 |
return response.data;
|
| 88 |
};
|
| 89 |
|
| 90 |
-
//
|
| 91 |
export const streamChatRequest = async (
|
| 92 |
sessionId: string,
|
| 93 |
userMessage: string,
|
|
@@ -107,9 +109,6 @@ export const streamChatRequest = async (
|
|
| 107 |
Authorization: `Bearer ${token}`,
|
| 108 |
"Content-Type": "application/json",
|
| 109 |
},
|
| 110 |
-
// Note: Your backend expects query params for user_prompt based on the route signature:
|
| 111 |
-
// @router.post("/chat/{session_id}") async def chat_session(..., user_prompt: str, ...)
|
| 112 |
-
// It is NOT a JSON body in your current backend code (check notes.py:207).
|
| 113 |
}
|
| 114 |
);
|
| 115 |
|
|
|
|
|
|
|
| 1 |
import API from "./api"; // Your existing Axios instance
|
| 2 |
import { type AxiosResponse } from "axios";
|
| 3 |
|
|
|
|
| 29 |
// 2. Upload a new PDF
|
| 30 |
export const uploadNote = async (file: File): Promise<Note> => {
|
| 31 |
const formData = new FormData();
|
| 32 |
+
formData.append("file", file);
|
| 33 |
|
| 34 |
const response = await API.post("/notes/upload_notes", formData, {
|
| 35 |
headers: {
|
|
|
|
| 37 |
},
|
| 38 |
});
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
return {
|
| 41 |
id: response.data.doc_id,
|
| 42 |
filename: response.data.filename,
|
| 43 |
+
created_at: new Date().toISOString(),
|
| 44 |
};
|
| 45 |
};
|
| 46 |
|
| 47 |
+
// 3. NEW: Delete a note
|
| 48 |
+
export const deleteNote = async (noteId: number) => {
|
| 49 |
+
const response = await API.delete(`/notes/${noteId}`);
|
| 50 |
+
return response.data;
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
// 4. NEW: Rename a note
|
| 54 |
+
export const renameNote = async (noteId: number, newName: string) => {
|
| 55 |
+
const response = await API.put(`/notes/${noteId}`, { new_filename: newName });
|
| 56 |
+
return response.data;
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
// 5. Get the URL for the PDF content (for the viewer)
|
| 60 |
export const getNoteContentUrl = (noteId: number): string => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return `/api/v1/notes/${noteId}/content`;
|
| 62 |
};
|
| 63 |
|
| 64 |
+
// 6. Fetch the Blob directly
|
| 65 |
export const fetchNoteBlob = async (noteId: number): Promise<Blob> => {
|
| 66 |
const response = await API.get(`/notes/${noteId}/content`, {
|
| 67 |
responseType: "blob",
|
|
|
|
| 69 |
return response.data;
|
| 70 |
};
|
| 71 |
|
| 72 |
+
// 7. Create or Get Chat Session
|
| 73 |
export const createChatSession = async (
|
| 74 |
pdfId: number,
|
| 75 |
name: string = "New Chat"
|
|
|
|
| 78 |
return response.data;
|
| 79 |
};
|
| 80 |
|
| 81 |
+
// 8. Get Chat History
|
| 82 |
export const fetchChatHistory = async (sessionId: string) => {
|
| 83 |
const response = await API.get(`/notes/history/${sessionId}`);
|
| 84 |
return response.data;
|
|
|
|
| 89 |
return response.data;
|
| 90 |
};
|
| 91 |
|
| 92 |
+
// 9. Stream Chat (Special Handling using fetch API)
|
| 93 |
export const streamChatRequest = async (
|
| 94 |
sessionId: string,
|
| 95 |
userMessage: string,
|
|
|
|
| 109 |
Authorization: `Bearer ${token}`,
|
| 110 |
"Content-Type": "application/json",
|
| 111 |
},
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
);
|
| 114 |
|
Frontend/src/components/context/AuthContext.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect } from "react";
|
|
| 2 |
|
| 3 |
interface AuthContextType {
|
| 4 |
isAuthenticated: boolean;
|
|
|
|
| 5 |
username: string;
|
| 6 |
login: (name: string, token: string) => void;
|
| 7 |
logout: () => void;
|
|
@@ -12,15 +13,20 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
| 12 |
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
| 13 |
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 14 |
const [username, setUsername] = useState("Guest");
|
|
|
|
| 15 |
|
| 16 |
-
// ✅ FIX: Check localStorage on mount to persist session
|
| 17 |
useEffect(() => {
|
|
|
|
| 18 |
const token = localStorage.getItem("token");
|
| 19 |
const storedUser = localStorage.getItem("username");
|
|
|
|
| 20 |
if (token) {
|
| 21 |
setIsAuthenticated(true);
|
| 22 |
if (storedUser) setUsername(storedUser);
|
| 23 |
}
|
|
|
|
|
|
|
|
|
|
| 24 |
}, []);
|
| 25 |
|
| 26 |
const login = (name: string, token: string) => {
|
|
@@ -38,7 +44,9 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
|
| 38 |
};
|
| 39 |
|
| 40 |
return (
|
| 41 |
-
<AuthContext.Provider
|
|
|
|
|
|
|
| 42 |
{children}
|
| 43 |
</AuthContext.Provider>
|
| 44 |
);
|
|
|
|
| 2 |
|
| 3 |
interface AuthContextType {
|
| 4 |
isAuthenticated: boolean;
|
| 5 |
+
isLoading: boolean; // <--- Added: Tracks if we are still checking local storage
|
| 6 |
username: string;
|
| 7 |
login: (name: string, token: string) => void;
|
| 8 |
logout: () => void;
|
|
|
|
| 13 |
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
| 14 |
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
| 15 |
const [username, setUsername] = useState("Guest");
|
| 16 |
+
const [isLoading, setIsLoading] = useState(true); // <--- Start loading immediately
|
| 17 |
|
|
|
|
| 18 |
useEffect(() => {
|
| 19 |
+
// Check local storage on initial load
|
| 20 |
const token = localStorage.getItem("token");
|
| 21 |
const storedUser = localStorage.getItem("username");
|
| 22 |
+
|
| 23 |
if (token) {
|
| 24 |
setIsAuthenticated(true);
|
| 25 |
if (storedUser) setUsername(storedUser);
|
| 26 |
}
|
| 27 |
+
|
| 28 |
+
// Done checking, stop loading
|
| 29 |
+
setIsLoading(false);
|
| 30 |
}, []);
|
| 31 |
|
| 32 |
const login = (name: string, token: string) => {
|
|
|
|
| 44 |
};
|
| 45 |
|
| 46 |
return (
|
| 47 |
+
<AuthContext.Provider
|
| 48 |
+
value={{ isAuthenticated, isLoading, username, login, logout }}
|
| 49 |
+
>
|
| 50 |
{children}
|
| 51 |
</AuthContext.Provider>
|
| 52 |
);
|
Frontend/src/pages/note.tsx
CHANGED
|
@@ -9,6 +9,10 @@ import {
|
|
| 9 |
Loader2,
|
| 10 |
FileText,
|
| 11 |
MessageSquare,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
} from "lucide-react";
|
| 13 |
import {
|
| 14 |
fetchNotes,
|
|
@@ -17,7 +21,9 @@ import {
|
|
| 17 |
createChatSession,
|
| 18 |
streamChatRequest,
|
| 19 |
fetchChatHistory,
|
| 20 |
-
fetchSessions,
|
|
|
|
|
|
|
| 21 |
type Note,
|
| 22 |
type ChatMessage,
|
| 23 |
} from "../api/notesService";
|
|
@@ -29,8 +35,8 @@ const Notes: React.FC = () => {
|
|
| 29 |
const [isUploading, setIsUploading] = useState(false);
|
| 30 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 31 |
|
| 32 |
-
//
|
| 33 |
-
const [chatWidth, setChatWidth] = useState(450);
|
| 34 |
const [isResizing, setIsResizing] = useState(false);
|
| 35 |
|
| 36 |
// --- Data State ---
|
|
@@ -38,29 +44,45 @@ const Notes: React.FC = () => {
|
|
| 38 |
const [currentNote, setCurrentNote] = useState<Note | null>(null);
|
| 39 |
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
// --- Chat State ---
|
| 42 |
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
| 43 |
const [inputMessage, setInputMessage] = useState("");
|
| 44 |
const [sessionId, setSessionId] = useState<string | null>(null);
|
| 45 |
const [isChatLoading, setIsChatLoading] = useState(false);
|
| 46 |
|
| 47 |
-
//
|
|
|
|
|
|
|
| 48 |
useEffect(() => {
|
| 49 |
loadNotes();
|
| 50 |
}, []);
|
| 51 |
|
| 52 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
const startResizing = useCallback(() => setIsResizing(true), []);
|
| 54 |
const stopResizing = useCallback(() => setIsResizing(false), []);
|
| 55 |
-
|
| 56 |
const resize = useCallback(
|
| 57 |
(mouseMoveEvent: MouseEvent) => {
|
| 58 |
if (isResizing) {
|
| 59 |
-
// Calculate new width based on mouse position from the right edge
|
| 60 |
const newWidth = document.body.clientWidth - mouseMoveEvent.clientX;
|
| 61 |
-
if (newWidth > 300 && newWidth < 800)
|
| 62 |
-
setChatWidth(newWidth);
|
| 63 |
-
}
|
| 64 |
}
|
| 65 |
},
|
| 66 |
[isResizing]
|
|
@@ -85,11 +107,9 @@ const Notes: React.FC = () => {
|
|
| 85 |
};
|
| 86 |
|
| 87 |
const handleUploadClick = () => fileInputRef.current?.click();
|
| 88 |
-
|
| 89 |
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 90 |
const file = e.target.files?.[0];
|
| 91 |
if (!file) return;
|
| 92 |
-
|
| 93 |
setIsUploading(true);
|
| 94 |
try {
|
| 95 |
const newNote = await uploadNote(file);
|
|
@@ -103,42 +123,93 @@ const Notes: React.FC = () => {
|
|
| 103 |
}
|
| 104 |
};
|
| 105 |
|
| 106 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
const handleNoteSelect = async (note: Note) => {
|
|
|
|
| 108 |
setCurrentNote(note);
|
| 109 |
setPdfUrl(null);
|
| 110 |
setMessages([]);
|
| 111 |
setSessionId(null);
|
| 112 |
-
setIsChatOpen(true);
|
| 113 |
|
| 114 |
-
// A. Fetch PDF Blob
|
| 115 |
try {
|
| 116 |
const blob = await fetchNoteBlob(note.id);
|
| 117 |
const url = URL.createObjectURL(blob);
|
| 118 |
setPdfUrl(url);
|
| 119 |
} catch (error) {
|
| 120 |
-
console.error("Failed to load PDF
|
| 121 |
}
|
| 122 |
|
| 123 |
-
// B. Check for existing sessions -> Get History OR Create New
|
| 124 |
try {
|
| 125 |
const existingSessions = await fetchSessions(note.id);
|
| 126 |
-
|
| 127 |
if (existingSessions.length > 0) {
|
| 128 |
-
// Load the most recent session
|
| 129 |
const lastSession = existingSessions[0];
|
| 130 |
setSessionId(lastSession.id);
|
| 131 |
-
|
| 132 |
-
// Fetch actual messages
|
| 133 |
const history = await fetchChatHistory(lastSession.id);
|
| 134 |
-
// Map backend history to frontend format
|
| 135 |
const formattedHistory: ChatMessage[] = history.map((msg: any) => ({
|
| 136 |
role: msg.role,
|
| 137 |
content: msg.content,
|
| 138 |
}));
|
| 139 |
setMessages(formattedHistory);
|
| 140 |
} else {
|
| 141 |
-
// No session exists, create one
|
| 142 |
const session = await createChatSession(
|
| 143 |
note.id,
|
| 144 |
`Chat - ${note.filename}`
|
|
@@ -152,23 +223,17 @@ const Notes: React.FC = () => {
|
|
| 152 |
]);
|
| 153 |
}
|
| 154 |
} catch (error) {
|
| 155 |
-
console.error("Failed to init chat
|
| 156 |
}
|
| 157 |
};
|
| 158 |
|
| 159 |
-
// ✅ FIX: Loading State Bug (Issue 4)
|
| 160 |
const handleSendMessage = async () => {
|
| 161 |
if (!inputMessage.trim() || !sessionId) return;
|
| 162 |
-
|
| 163 |
const userMsg = inputMessage;
|
| 164 |
setInputMessage("");
|
| 165 |
-
|
| 166 |
setMessages((prev) => [...prev, { role: "user", content: userMsg }]);
|
| 167 |
-
setIsChatLoading(true);
|
| 168 |
-
|
| 169 |
-
// Placeholder
|
| 170 |
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
|
| 171 |
-
|
| 172 |
try {
|
| 173 |
await streamChatRequest(
|
| 174 |
sessionId,
|
|
@@ -184,22 +249,18 @@ const Notes: React.FC = () => {
|
|
| 184 |
return newArr;
|
| 185 |
});
|
| 186 |
},
|
| 187 |
-
(err) =>
|
| 188 |
-
console.error("Stream error", err);
|
| 189 |
-
// Don't set loading false here, let finally handle it
|
| 190 |
-
}
|
| 191 |
);
|
| 192 |
} catch (e) {
|
| 193 |
console.error("Chat Request Error", e);
|
| 194 |
} finally {
|
| 195 |
-
// ✅ Ensure loading stops regardless of success/fail so button enables
|
| 196 |
setIsChatLoading(false);
|
| 197 |
}
|
| 198 |
};
|
| 199 |
|
| 200 |
return (
|
| 201 |
<div className="flex bg-black h-screen overflow-hidden">
|
| 202 |
-
{/* --- Left Sidebar
|
| 203 |
<div
|
| 204 |
className={`h-screen shrink-0 transition-all duration-300 bg-gray-900 border-r border-gray-700 flex flex-col gap-4 ${
|
| 205 |
isSidebarOpen ? "w-64 p-4" : "w-0 p-0 overflow-hidden"
|
|
@@ -229,22 +290,74 @@ const Notes: React.FC = () => {
|
|
| 229 |
)}
|
| 230 |
{isUploading ? "Uploading..." : "Upload New PDF"}
|
| 231 |
</button>
|
|
|
|
| 232 |
<div className="mt-4 pt-4 border-t border-gray-700 space-y-2 overflow-y-auto">
|
| 233 |
<p className="text-sm text-gray-400 uppercase tracking-wider">
|
| 234 |
History
|
| 235 |
</p>
|
|
|
|
| 236 |
{notes.map((note) => (
|
| 237 |
<div
|
| 238 |
key={note.id}
|
| 239 |
onClick={() => handleNoteSelect(note)}
|
| 240 |
-
className={`text-gray-200 p-3 rounded-md cursor-pointer flex items-center
|
| 241 |
currentNote?.id === note.id
|
| 242 |
? "bg-gray-800 border border-blue-500"
|
| 243 |
: ""
|
| 244 |
}`}
|
| 245 |
>
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
</div>
|
| 249 |
))}
|
| 250 |
</div>
|
|
@@ -253,135 +366,141 @@ const Notes: React.FC = () => {
|
|
| 253 |
</div>
|
| 254 |
|
| 255 |
{/* --- Center: PDF Viewer --- */}
|
| 256 |
-
<div className="flex flex-1 overflow-hidden relative">
|
| 257 |
-
<
|
| 258 |
-
<
|
| 259 |
<button
|
| 260 |
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
| 261 |
-
className="p-2 rounded-
|
| 262 |
>
|
| 263 |
-
|
| 264 |
</button>
|
| 265 |
<h2 className="text-lg font-semibold text-white truncate max-w-md">
|
| 266 |
{currentNote ? currentNote.filename : "Select a Note"}
|
| 267 |
</h2>
|
| 268 |
-
{!isChatOpen && (
|
| 269 |
-
<button
|
| 270 |
-
onClick={() => setIsChatOpen(true)}
|
| 271 |
-
className="flex items-center gap-2 bg-blue-600 text-white px-3 py-2 rounded-lg text-sm"
|
| 272 |
-
>
|
| 273 |
-
<MessageSquare size={16} /> Chat
|
| 274 |
-
</button>
|
| 275 |
-
)}
|
| 276 |
-
</header>
|
| 277 |
-
|
| 278 |
-
<div className="flex-1 w-full h-full pt-16">
|
| 279 |
-
{pdfUrl ? (
|
| 280 |
-
<iframe
|
| 281 |
-
src={pdfUrl}
|
| 282 |
-
className="w-full h-full border-none"
|
| 283 |
-
title="PDF Viewer"
|
| 284 |
-
/>
|
| 285 |
-
) : (
|
| 286 |
-
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
| 287 |
-
<FileText size={64} className="mb-4 opacity-50" />
|
| 288 |
-
<p>Select a PDF from the sidebar to view</p>
|
| 289 |
-
</div>
|
| 290 |
-
)}
|
| 291 |
</div>
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
|
|
|
|
|
|
| 306 |
<div
|
| 307 |
-
|
| 308 |
-
|
| 309 |
>
|
| 310 |
-
{
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
>
|
| 319 |
-
<X size={20} />
|
| 320 |
-
</button>
|
| 321 |
-
</header>
|
| 322 |
-
|
| 323 |
-
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
| 324 |
-
{messages.length === 0 && (
|
| 325 |
-
<p className="text-gray-500 text-center text-sm mt-10">
|
| 326 |
-
Ask a question about this document...
|
| 327 |
-
</p>
|
| 328 |
-
)}
|
| 329 |
-
{messages.map((msg, i) => (
|
| 330 |
<div
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
| 334 |
}`}
|
| 335 |
>
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
msg.
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
{msg.role === "assistant" ? (
|
| 344 |
-
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 345 |
-
{msg.content}
|
| 346 |
-
</ReactMarkdown>
|
| 347 |
-
) : (
|
| 348 |
-
msg.content
|
| 349 |
-
)}
|
| 350 |
-
</div>
|
| 351 |
</div>
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
</div>
|
| 359 |
-
)}
|
| 360 |
-
</div>
|
| 361 |
-
|
| 362 |
-
<div className="p-4 border-t border-gray-700">
|
| 363 |
-
<div className="flex gap-2">
|
| 364 |
-
<input
|
| 365 |
-
type="text"
|
| 366 |
-
value={inputMessage}
|
| 367 |
-
onChange={(e) => setInputMessage(e.target.value)}
|
| 368 |
-
onKeyDown={(e) => e.key === "Enter" && handleSendMessage()}
|
| 369 |
-
placeholder="Type your question..."
|
| 370 |
-
className="flex-1 bg-gray-800 text-white rounded-lg px-4 py-2 border border-gray-700 focus:outline-none focus:border-blue-500"
|
| 371 |
-
disabled={!sessionId || isChatLoading}
|
| 372 |
-
/>
|
| 373 |
-
<button
|
| 374 |
-
onClick={handleSendMessage}
|
| 375 |
-
disabled={!sessionId || isChatLoading}
|
| 376 |
-
className="bg-blue-600 p-2 rounded-lg text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 377 |
-
>
|
| 378 |
-
<Send size={20} />
|
| 379 |
-
</button>
|
| 380 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</div>
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
);
|
|
|
|
| 9 |
Loader2,
|
| 10 |
FileText,
|
| 11 |
MessageSquare,
|
| 12 |
+
GripVertical,
|
| 13 |
+
Trash2,
|
| 14 |
+
Edit2,
|
| 15 |
+
Check,
|
| 16 |
} from "lucide-react";
|
| 17 |
import {
|
| 18 |
fetchNotes,
|
|
|
|
| 21 |
createChatSession,
|
| 22 |
streamChatRequest,
|
| 23 |
fetchChatHistory,
|
| 24 |
+
fetchSessions,
|
| 25 |
+
deleteNote,
|
| 26 |
+
renameNote,
|
| 27 |
type Note,
|
| 28 |
type ChatMessage,
|
| 29 |
} from "../api/notesService";
|
|
|
|
| 35 |
const [isUploading, setIsUploading] = useState(false);
|
| 36 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 37 |
|
| 38 |
+
// --- Resizable Chat State ---
|
| 39 |
+
const [chatWidth, setChatWidth] = useState(450);
|
| 40 |
const [isResizing, setIsResizing] = useState(false);
|
| 41 |
|
| 42 |
// --- Data State ---
|
|
|
|
| 44 |
const [currentNote, setCurrentNote] = useState<Note | null>(null);
|
| 45 |
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
| 46 |
|
| 47 |
+
// --- Edit/Rename State ---
|
| 48 |
+
const [editingNoteId, setEditingNoteId] = useState<number | null>(null);
|
| 49 |
+
const [editName, setEditName] = useState("");
|
| 50 |
+
|
| 51 |
// --- Chat State ---
|
| 52 |
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
| 53 |
const [inputMessage, setInputMessage] = useState("");
|
| 54 |
const [sessionId, setSessionId] = useState<string | null>(null);
|
| 55 |
const [isChatLoading, setIsChatLoading] = useState(false);
|
| 56 |
|
| 57 |
+
// --- Auto Scroll Ref ---
|
| 58 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 59 |
+
|
| 60 |
useEffect(() => {
|
| 61 |
loadNotes();
|
| 62 |
}, []);
|
| 63 |
|
| 64 |
+
// --- FIX: Robust Auto-Scroll ---
|
| 65 |
+
const scrollToBottom = () => {
|
| 66 |
+
// "smooth" gets interrupted by rapid streaming updates.
|
| 67 |
+
// "instant" ensures it snaps to bottom every time a token arrives.
|
| 68 |
+
messagesEndRef.current?.scrollIntoView({
|
| 69 |
+
behavior: "instant",
|
| 70 |
+
block: "end",
|
| 71 |
+
});
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
scrollToBottom();
|
| 76 |
+
}, [messages, isChatLoading]);
|
| 77 |
+
|
| 78 |
+
// --- Resizing Logic ---
|
| 79 |
const startResizing = useCallback(() => setIsResizing(true), []);
|
| 80 |
const stopResizing = useCallback(() => setIsResizing(false), []);
|
|
|
|
| 81 |
const resize = useCallback(
|
| 82 |
(mouseMoveEvent: MouseEvent) => {
|
| 83 |
if (isResizing) {
|
|
|
|
| 84 |
const newWidth = document.body.clientWidth - mouseMoveEvent.clientX;
|
| 85 |
+
if (newWidth > 300 && newWidth < 800) setChatWidth(newWidth);
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
},
|
| 88 |
[isResizing]
|
|
|
|
| 107 |
};
|
| 108 |
|
| 109 |
const handleUploadClick = () => fileInputRef.current?.click();
|
|
|
|
| 110 |
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 111 |
const file = e.target.files?.[0];
|
| 112 |
if (!file) return;
|
|
|
|
| 113 |
setIsUploading(true);
|
| 114 |
try {
|
| 115 |
const newNote = await uploadNote(file);
|
|
|
|
| 123 |
}
|
| 124 |
};
|
| 125 |
|
| 126 |
+
// --- Handle Delete ---
|
| 127 |
+
const handleDeleteNote = async (e: React.MouseEvent, noteId: number) => {
|
| 128 |
+
e.stopPropagation();
|
| 129 |
+
if (
|
| 130 |
+
!window.confirm(
|
| 131 |
+
"Are you sure you want to delete this note and its chat history?"
|
| 132 |
+
)
|
| 133 |
+
)
|
| 134 |
+
return;
|
| 135 |
+
|
| 136 |
+
try {
|
| 137 |
+
await deleteNote(noteId);
|
| 138 |
+
setNotes((prev) => prev.filter((n) => n.id !== noteId));
|
| 139 |
+
if (currentNote?.id === noteId) {
|
| 140 |
+
setCurrentNote(null);
|
| 141 |
+
setPdfUrl(null);
|
| 142 |
+
setMessages([]);
|
| 143 |
+
setSessionId(null);
|
| 144 |
+
}
|
| 145 |
+
} catch (error) {
|
| 146 |
+
console.error("Failed to delete note", error);
|
| 147 |
+
alert("Error deleting note");
|
| 148 |
+
}
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
// --- Handle Rename ---
|
| 152 |
+
const startEditing = (e: React.MouseEvent, note: Note) => {
|
| 153 |
+
e.stopPropagation();
|
| 154 |
+
setEditingNoteId(note.id);
|
| 155 |
+
setEditName(note.filename);
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const saveRename = async (e: React.MouseEvent) => {
|
| 159 |
+
e.stopPropagation();
|
| 160 |
+
if (!editingNoteId || !editName.trim()) return;
|
| 161 |
+
try {
|
| 162 |
+
await renameNote(editingNoteId, editName);
|
| 163 |
+
setNotes((prev) =>
|
| 164 |
+
prev.map((n) =>
|
| 165 |
+
n.id === editingNoteId ? { ...n, filename: editName } : n
|
| 166 |
+
)
|
| 167 |
+
);
|
| 168 |
+
if (currentNote?.id === editingNoteId) {
|
| 169 |
+
setCurrentNote((prev) =>
|
| 170 |
+
prev ? { ...prev, filename: editName } : null
|
| 171 |
+
);
|
| 172 |
+
}
|
| 173 |
+
setEditingNoteId(null);
|
| 174 |
+
} catch (error) {
|
| 175 |
+
console.error("Failed to rename", error);
|
| 176 |
+
alert("Error renaming note");
|
| 177 |
+
}
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const cancelRename = (e: React.MouseEvent) => {
|
| 181 |
+
e.stopPropagation();
|
| 182 |
+
setEditingNoteId(null);
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
const handleNoteSelect = async (note: Note) => {
|
| 186 |
+
if (editingNoteId === note.id) return;
|
| 187 |
setCurrentNote(note);
|
| 188 |
setPdfUrl(null);
|
| 189 |
setMessages([]);
|
| 190 |
setSessionId(null);
|
| 191 |
+
setIsChatOpen(true);
|
| 192 |
|
|
|
|
| 193 |
try {
|
| 194 |
const blob = await fetchNoteBlob(note.id);
|
| 195 |
const url = URL.createObjectURL(blob);
|
| 196 |
setPdfUrl(url);
|
| 197 |
} catch (error) {
|
| 198 |
+
console.error("Failed to load PDF", error);
|
| 199 |
}
|
| 200 |
|
|
|
|
| 201 |
try {
|
| 202 |
const existingSessions = await fetchSessions(note.id);
|
|
|
|
| 203 |
if (existingSessions.length > 0) {
|
|
|
|
| 204 |
const lastSession = existingSessions[0];
|
| 205 |
setSessionId(lastSession.id);
|
|
|
|
|
|
|
| 206 |
const history = await fetchChatHistory(lastSession.id);
|
|
|
|
| 207 |
const formattedHistory: ChatMessage[] = history.map((msg: any) => ({
|
| 208 |
role: msg.role,
|
| 209 |
content: msg.content,
|
| 210 |
}));
|
| 211 |
setMessages(formattedHistory);
|
| 212 |
} else {
|
|
|
|
| 213 |
const session = await createChatSession(
|
| 214 |
note.id,
|
| 215 |
`Chat - ${note.filename}`
|
|
|
|
| 223 |
]);
|
| 224 |
}
|
| 225 |
} catch (error) {
|
| 226 |
+
console.error("Failed to init chat", error);
|
| 227 |
}
|
| 228 |
};
|
| 229 |
|
|
|
|
| 230 |
const handleSendMessage = async () => {
|
| 231 |
if (!inputMessage.trim() || !sessionId) return;
|
|
|
|
| 232 |
const userMsg = inputMessage;
|
| 233 |
setInputMessage("");
|
|
|
|
| 234 |
setMessages((prev) => [...prev, { role: "user", content: userMsg }]);
|
| 235 |
+
setIsChatLoading(true);
|
|
|
|
|
|
|
| 236 |
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
|
|
|
|
| 237 |
try {
|
| 238 |
await streamChatRequest(
|
| 239 |
sessionId,
|
|
|
|
| 249 |
return newArr;
|
| 250 |
});
|
| 251 |
},
|
| 252 |
+
(err) => console.error("Stream error", err)
|
|
|
|
|
|
|
|
|
|
| 253 |
);
|
| 254 |
} catch (e) {
|
| 255 |
console.error("Chat Request Error", e);
|
| 256 |
} finally {
|
|
|
|
| 257 |
setIsChatLoading(false);
|
| 258 |
}
|
| 259 |
};
|
| 260 |
|
| 261 |
return (
|
| 262 |
<div className="flex bg-black h-screen overflow-hidden">
|
| 263 |
+
{/* --- Left Sidebar --- */}
|
| 264 |
<div
|
| 265 |
className={`h-screen shrink-0 transition-all duration-300 bg-gray-900 border-r border-gray-700 flex flex-col gap-4 ${
|
| 266 |
isSidebarOpen ? "w-64 p-4" : "w-0 p-0 overflow-hidden"
|
|
|
|
| 290 |
)}
|
| 291 |
{isUploading ? "Uploading..." : "Upload New PDF"}
|
| 292 |
</button>
|
| 293 |
+
|
| 294 |
<div className="mt-4 pt-4 border-t border-gray-700 space-y-2 overflow-y-auto">
|
| 295 |
<p className="text-sm text-gray-400 uppercase tracking-wider">
|
| 296 |
History
|
| 297 |
</p>
|
| 298 |
+
|
| 299 |
{notes.map((note) => (
|
| 300 |
<div
|
| 301 |
key={note.id}
|
| 302 |
onClick={() => handleNoteSelect(note)}
|
| 303 |
+
className={`group relative text-gray-200 p-3 rounded-md cursor-pointer flex items-center justify-between hover:bg-gray-700 transition ${
|
| 304 |
currentNote?.id === note.id
|
| 305 |
? "bg-gray-800 border border-blue-500"
|
| 306 |
: ""
|
| 307 |
}`}
|
| 308 |
>
|
| 309 |
+
{editingNoteId === note.id ? (
|
| 310 |
+
<div className="flex items-center gap-2 w-full">
|
| 311 |
+
<input
|
| 312 |
+
type="text"
|
| 313 |
+
value={editName}
|
| 314 |
+
onChange={(e) => setEditName(e.target.value)}
|
| 315 |
+
onClick={(e) => e.stopPropagation()}
|
| 316 |
+
className="flex-1 bg-gray-900 text-white text-xs px-2 py-1 rounded border border-blue-500 outline-none"
|
| 317 |
+
autoFocus
|
| 318 |
+
/>
|
| 319 |
+
<button
|
| 320 |
+
onClick={saveRename}
|
| 321 |
+
className="text-green-400 hover:text-green-300 p-1"
|
| 322 |
+
>
|
| 323 |
+
<Check size={14} />
|
| 324 |
+
</button>
|
| 325 |
+
<button
|
| 326 |
+
onClick={cancelRename}
|
| 327 |
+
className="text-red-400 hover:text-red-300 p-1"
|
| 328 |
+
>
|
| 329 |
+
<X size={14} />
|
| 330 |
+
</button>
|
| 331 |
+
</div>
|
| 332 |
+
) : (
|
| 333 |
+
<>
|
| 334 |
+
<div className="flex items-center gap-2 overflow-hidden">
|
| 335 |
+
<FileText
|
| 336 |
+
size={16}
|
| 337 |
+
className="text-blue-400 shrink-0"
|
| 338 |
+
/>
|
| 339 |
+
<span className="truncate text-sm">
|
| 340 |
+
{note.filename}
|
| 341 |
+
</span>
|
| 342 |
+
</div>
|
| 343 |
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 344 |
+
<button
|
| 345 |
+
onClick={(e) => startEditing(e, note)}
|
| 346 |
+
className="p-1.5 hover:bg-gray-600 rounded text-gray-400 hover:text-white"
|
| 347 |
+
title="Rename"
|
| 348 |
+
>
|
| 349 |
+
<Edit2 size={14} />
|
| 350 |
+
</button>
|
| 351 |
+
<button
|
| 352 |
+
onClick={(e) => handleDeleteNote(e, note.id)}
|
| 353 |
+
className="p-1.5 hover:bg-red-900/50 rounded text-gray-400 hover:text-red-400"
|
| 354 |
+
title="Delete"
|
| 355 |
+
>
|
| 356 |
+
<Trash2 size={14} />
|
| 357 |
+
</button>
|
| 358 |
+
</div>
|
| 359 |
+
</>
|
| 360 |
+
)}
|
| 361 |
</div>
|
| 362 |
))}
|
| 363 |
</div>
|
|
|
|
| 366 |
</div>
|
| 367 |
|
| 368 |
{/* --- Center: PDF Viewer --- */}
|
| 369 |
+
<div className="flex flex-1 overflow-hidden relative flex-col">
|
| 370 |
+
<header className="flex justify-between items-center p-4 bg-gray-900 border-b border-gray-700 shrink-0 z-10">
|
| 371 |
+
<div className="flex items-center gap-3">
|
| 372 |
<button
|
| 373 |
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
| 374 |
+
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-white transition"
|
| 375 |
>
|
| 376 |
+
<Menu size={20} />
|
| 377 |
</button>
|
| 378 |
<h2 className="text-lg font-semibold text-white truncate max-w-md">
|
| 379 |
{currentNote ? currentNote.filename : "Select a Note"}
|
| 380 |
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
</div>
|
| 382 |
+
{!isChatOpen && currentNote && (
|
| 383 |
+
<button
|
| 384 |
+
onClick={() => setIsChatOpen(true)}
|
| 385 |
+
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition"
|
| 386 |
+
>
|
| 387 |
+
<MessageSquare size={18} /> Open Chat
|
| 388 |
+
</button>
|
| 389 |
+
)}
|
| 390 |
+
</header>
|
| 391 |
|
| 392 |
+
<div className="flex-1 w-full h-full bg-gray-800 relative">
|
| 393 |
+
{pdfUrl ? (
|
| 394 |
+
<iframe
|
| 395 |
+
src={pdfUrl}
|
| 396 |
+
className={`w-full h-full border-none ${
|
| 397 |
+
isResizing ? "pointer-events-none" : ""
|
| 398 |
+
}`}
|
| 399 |
+
title="PDF Viewer"
|
| 400 |
+
/>
|
| 401 |
+
) : (
|
| 402 |
+
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
| 403 |
+
<FileText size={64} className="mb-4 opacity-50" />
|
| 404 |
+
<p>Select a PDF from the sidebar to view</p>
|
| 405 |
+
</div>
|
| 406 |
+
)}
|
| 407 |
+
</div>
|
| 408 |
+
</div>
|
| 409 |
|
| 410 |
+
{/* --- Resizable Chat Panel --- */}
|
| 411 |
+
{isChatOpen && (
|
| 412 |
<div
|
| 413 |
+
className="w-1.5 hover:w-2 cursor-col-resize bg-gray-800 hover:bg-blue-500 transition-all z-20 flex items-center justify-center shrink-0"
|
| 414 |
+
onMouseDown={startResizing}
|
| 415 |
>
|
| 416 |
+
<GripVertical size={16} className="text-gray-500" />
|
| 417 |
+
</div>
|
| 418 |
+
)}
|
| 419 |
+
|
| 420 |
+
<div
|
| 421 |
+
style={{ width: isChatOpen ? chatWidth : 0 }}
|
| 422 |
+
className={`flex flex-col bg-gray-900 border-l border-gray-700 shrink-0 transition-all duration-100 ease-linear overflow-hidden`}
|
| 423 |
+
>
|
| 424 |
+
{isChatOpen && (
|
| 425 |
+
<>
|
| 426 |
+
<header className="flex justify-between items-center p-4 border-b border-gray-700 bg-gray-900 shrink-0">
|
| 427 |
+
<div className="flex items-center gap-2">
|
| 428 |
+
<MessageSquare size={18} className="text-blue-500" />
|
| 429 |
+
<h3 className="text-lg font-bold text-white whitespace-nowrap">
|
| 430 |
+
AI Chat
|
| 431 |
+
</h3>
|
| 432 |
+
</div>
|
| 433 |
+
<button
|
| 434 |
+
onClick={() => setIsChatOpen(false)}
|
| 435 |
+
className="p-2 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
| 436 |
+
>
|
| 437 |
+
<X size={20} />
|
| 438 |
+
</button>
|
| 439 |
+
</header>
|
| 440 |
+
|
| 441 |
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
| 442 |
+
{messages.length === 0 && (
|
| 443 |
+
<div className="text-center text-gray-500 mt-10">
|
| 444 |
+
<p>Ask a question about this document...</p>
|
| 445 |
+
</div>
|
| 446 |
+
)}
|
| 447 |
+
{messages.map((msg, i) => (
|
| 448 |
+
<div
|
| 449 |
+
key={i}
|
| 450 |
+
className={`flex ${
|
| 451 |
+
msg.role === "user" ? "justify-end" : "justify-start"
|
| 452 |
+
}`}
|
| 453 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
<div
|
| 455 |
+
className={`max-w-[85%] p-3 rounded-lg text-sm ${
|
| 456 |
+
msg.role === "user"
|
| 457 |
+
? "bg-blue-600 text-white"
|
| 458 |
+
: "bg-gray-700 text-gray-200 prose prose-invert prose-sm max-w-none"
|
| 459 |
}`}
|
| 460 |
>
|
| 461 |
+
{msg.role === "assistant" ? (
|
| 462 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 463 |
+
{msg.content}
|
| 464 |
+
</ReactMarkdown>
|
| 465 |
+
) : (
|
| 466 |
+
msg.content
|
| 467 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
</div>
|
| 469 |
+
</div>
|
| 470 |
+
))}
|
| 471 |
+
{isChatLoading && (
|
| 472 |
+
<div className="flex justify-start">
|
| 473 |
+
<div className="bg-gray-700 p-3 rounded-lg">
|
| 474 |
+
<Loader2 className="animate-spin w-4 h-4 text-blue-400" />
|
| 475 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
</div>
|
| 477 |
+
)}
|
| 478 |
+
{/* Auto Scroll Anchor */}
|
| 479 |
+
<div ref={messagesEndRef} />
|
| 480 |
+
</div>
|
| 481 |
+
|
| 482 |
+
<div className="p-4 border-t border-gray-700 shrink-0">
|
| 483 |
+
<div className="flex gap-2">
|
| 484 |
+
<input
|
| 485 |
+
type="text"
|
| 486 |
+
value={inputMessage}
|
| 487 |
+
onChange={(e) => setInputMessage(e.target.value)}
|
| 488 |
+
onKeyDown={(e) => e.key === "Enter" && handleSendMessage()}
|
| 489 |
+
placeholder="Type your question..."
|
| 490 |
+
className="flex-1 bg-gray-800 text-white rounded-lg px-4 py-2 border border-gray-700 focus:outline-none focus:border-blue-500 placeholder-gray-500"
|
| 491 |
+
disabled={!sessionId || isChatLoading}
|
| 492 |
+
/>
|
| 493 |
+
<button
|
| 494 |
+
onClick={handleSendMessage}
|
| 495 |
+
disabled={!sessionId || isChatLoading}
|
| 496 |
+
className="bg-blue-600 p-2 rounded-lg text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 497 |
+
>
|
| 498 |
+
<Send size={20} />
|
| 499 |
+
</button>
|
| 500 |
</div>
|
| 501 |
+
</div>
|
| 502 |
+
</>
|
| 503 |
+
)}
|
| 504 |
</div>
|
| 505 |
</div>
|
| 506 |
);
|