Spaces:
Running
Running
chore: merge upstream dev
Browse files- .github/dependabot.yml +78 -0
- README.md +2 -1
- backend/app/models.py +1 -0
- backend/app/routes/auth.py +5 -0
- backend/app/routes/chat.py +97 -11
- backend/app/routes/documents.py +30 -4
- backend/app/schemas.py +11 -0
- backend/requirements.txt +1 -0
- backend/scripts/vector_cleanup.py +128 -0
- frontend/package-lock.json +31 -124
- frontend/package.json +2 -1
- frontend/src/components/chat/ChatPanel.tsx +27 -23
- frontend/src/components/chat/MessageBubble.tsx +1 -1
- frontend/src/components/chat/SourceCard.tsx +1 -1
- frontend/src/lib/auth.tsx +12 -96
- frontend/src/store/auth-store.ts +124 -0
- frontend/src/store/chat-store.ts +68 -0
.github/dependabot.yml
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
updates:
|
| 3 |
+
- package-ecosystem: pip
|
| 4 |
+
directory: /
|
| 5 |
+
target-branch: dev
|
| 6 |
+
schedule:
|
| 7 |
+
interval: weekly
|
| 8 |
+
day: monday
|
| 9 |
+
time: "09:00"
|
| 10 |
+
timezone: Asia/Kolkata
|
| 11 |
+
open-pull-requests-limit: 5
|
| 12 |
+
labels:
|
| 13 |
+
- dependencies
|
| 14 |
+
- python
|
| 15 |
+
groups:
|
| 16 |
+
root-python-minor-patch:
|
| 17 |
+
update-types:
|
| 18 |
+
- minor
|
| 19 |
+
- patch
|
| 20 |
+
|
| 21 |
+
- package-ecosystem: pip
|
| 22 |
+
directory: /backend
|
| 23 |
+
target-branch: dev
|
| 24 |
+
schedule:
|
| 25 |
+
interval: weekly
|
| 26 |
+
day: monday
|
| 27 |
+
time: "09:15"
|
| 28 |
+
timezone: Asia/Kolkata
|
| 29 |
+
open-pull-requests-limit: 5
|
| 30 |
+
labels:
|
| 31 |
+
- dependencies
|
| 32 |
+
- python
|
| 33 |
+
- backend
|
| 34 |
+
groups:
|
| 35 |
+
backend-python-minor-patch:
|
| 36 |
+
update-types:
|
| 37 |
+
- minor
|
| 38 |
+
- patch
|
| 39 |
+
|
| 40 |
+
- package-ecosystem: npm
|
| 41 |
+
directory: /frontend
|
| 42 |
+
target-branch: dev
|
| 43 |
+
schedule:
|
| 44 |
+
interval: weekly
|
| 45 |
+
day: monday
|
| 46 |
+
time: "09:30"
|
| 47 |
+
timezone: Asia/Kolkata
|
| 48 |
+
open-pull-requests-limit: 5
|
| 49 |
+
labels:
|
| 50 |
+
- dependencies
|
| 51 |
+
- javascript
|
| 52 |
+
- frontend
|
| 53 |
+
groups:
|
| 54 |
+
frontend-npm-minor-patch:
|
| 55 |
+
update-types:
|
| 56 |
+
- minor
|
| 57 |
+
- patch
|
| 58 |
+
ignore:
|
| 59 |
+
- dependency-name: "eslint"
|
| 60 |
+
versions: [">= 10.0.0"]
|
| 61 |
+
|
| 62 |
+
- package-ecosystem: github-actions
|
| 63 |
+
directory: /
|
| 64 |
+
target-branch: dev
|
| 65 |
+
schedule:
|
| 66 |
+
interval: weekly
|
| 67 |
+
day: monday
|
| 68 |
+
time: "09:45"
|
| 69 |
+
timezone: Asia/Kolkata
|
| 70 |
+
open-pull-requests-limit: 5
|
| 71 |
+
labels:
|
| 72 |
+
- dependencies
|
| 73 |
+
- github-actions
|
| 74 |
+
groups:
|
| 75 |
+
github-actions-minor-patch:
|
| 76 |
+
update-types:
|
| 77 |
+
- minor
|
| 78 |
+
- patch
|
README.md
CHANGED
|
@@ -437,8 +437,9 @@ docker compose up --build
|
|
| 437 |
| `POST` | `/api/v1/auth/register` | ❌ | Create a new user account |
|
| 438 |
| `POST` | `/api/v1/auth/login` | ❌ | Login and receive JWT token |
|
| 439 |
| `GET` | `/api/v1/auth/me` | ✅ | Get current user profile |
|
| 440 |
-
| `POST` | `/api/v1/documents/upload` | ✅ | Upload PDF/DOCX and
|
| 441 |
| `GET` | `/api/v1/documents` | ✅ | List all documents for current user |
|
|
|
|
| 442 |
| `DELETE` | `/api/v1/documents/{id}` | ✅ | Delete a document and its vector data |
|
| 443 |
| `POST` | `/api/v1/chat/ask/stream` | ✅ | Ask a question (SSE streaming response) |
|
| 444 |
| `GET` | `/api/v1/chat/history/{doc_id}` | ✅ | Get chat history for a document |
|
|
|
|
| 437 |
| `POST` | `/api/v1/auth/register` | ❌ | Create a new user account |
|
| 438 |
| `POST` | `/api/v1/auth/login` | ❌ | Login and receive JWT token |
|
| 439 |
| `GET` | `/api/v1/auth/me` | ✅ | Get current user profile |
|
| 440 |
+
| `POST` | `/api/v1/documents/upload` | ✅ | Upload PDF/DOCX and enqueue background indexing (`202 Accepted`) |
|
| 441 |
| `GET` | `/api/v1/documents` | ✅ | List all documents for current user |
|
| 442 |
+
| `GET` | `/api/v1/documents/{id}/status` | ✅ | Poll background document processing status |
|
| 443 |
| `DELETE` | `/api/v1/documents/{id}` | ✅ | Delete a document and its vector data |
|
| 444 |
| `POST` | `/api/v1/chat/ask/stream` | ✅ | Ask a question (SSE streaming response) |
|
| 445 |
| `GET` | `/api/v1/chat/history/{doc_id}` | ✅ | Get chat history for a document |
|
backend/app/models.py
CHANGED
|
@@ -21,6 +21,7 @@ class User(Base):
|
|
| 21 |
hashed_password = Column(String(255), nullable=False)
|
| 22 |
is_admin = Column(Boolean, default=False)
|
| 23 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
|
|
|
| 24 |
|
| 25 |
# Relationships
|
| 26 |
documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
|
|
|
|
| 21 |
hashed_password = Column(String(255), nullable=False)
|
| 22 |
is_admin = Column(Boolean, default=False)
|
| 23 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 24 |
+
last_login = Column(DateTime, nullable=True, index=True)
|
| 25 |
|
| 26 |
# Relationships
|
| 27 |
documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
|
backend/app/routes/auth.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Auth API routes — register, login, and user profile.
|
| 3 |
"""
|
|
|
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 5 |
from langsmith import expect
|
| 6 |
from sqlalchemy.exc import SQLAlchemyError
|
|
@@ -105,6 +106,10 @@ def login(payload: UserLogin, db: Session = Depends(get_db)):
|
|
| 105 |
detail="Invalid email or password",
|
| 106 |
)
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
access_token = create_access_token(user.id)
|
| 109 |
refresh_token = create_refresh_token(user.id)
|
| 110 |
|
|
|
|
| 1 |
"""
|
| 2 |
Auth API routes — register, login, and user profile.
|
| 3 |
"""
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 6 |
from langsmith import expect
|
| 7 |
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
| 106 |
detail="Invalid email or password",
|
| 107 |
)
|
| 108 |
|
| 109 |
+
user.last_login = datetime.now(timezone.utc)
|
| 110 |
+
db.commit()
|
| 111 |
+
db.refresh(user)
|
| 112 |
+
|
| 113 |
access_token = create_access_token(user.id)
|
| 114 |
refresh_token = create_refresh_token(user.id)
|
| 115 |
|
backend/app/routes/chat.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
| 1 |
"""
|
| 2 |
Chat routes — ask questions with RAG, stream responses via SSE, manage history.
|
| 3 |
"""
|
|
|
|
| 4 |
import json
|
|
|
|
|
|
|
| 5 |
import logging
|
| 6 |
from typing import Optional
|
| 7 |
|
| 8 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 9 |
-
from fastapi.responses import StreamingResponse
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from sqlalchemy.orm import Session
|
| 11 |
|
| 12 |
from app.database import get_db
|
|
@@ -258,32 +265,31 @@ def export_chat_history(
|
|
| 258 |
):
|
| 259 |
"""Export the chat history for a document as a downloadable file.
|
| 260 |
|
| 261 |
-
Supports Markdown (.md)
|
| 262 |
authentication via either the standard `Authorization: Bearer <token>`
|
| 263 |
header (handled by the dependency chain) or a `token` query parameter to
|
| 264 |
facilitate browser-initiated downloads that cannot set custom headers.
|
| 265 |
|
| 266 |
Args:
|
| 267 |
document_id: The unique identifier of the document whose chat history is to be exported.
|
| 268 |
-
format: Output format, either "md" (Markdown)
|
| 269 |
token: Optional JWT token passed as a query parameter. Used for browser
|
| 270 |
downloads when the `Authorization` header is not available.
|
| 271 |
db: SQLAlchemy database session, obtained from the dependency.
|
| 272 |
|
| 273 |
Returns:
|
| 274 |
Response: A FastAPI `Response` object with:
|
| 275 |
-
- `content`: Formatted chat history as a string.
|
| 276 |
-
- `media_type`: `text/markdown`
|
| 277 |
- `headers`: `Content-Disposition` attachment header with a generated filename.
|
| 278 |
|
| 279 |
Raises:
|
| 280 |
HTTPException: 401 if neither the token query parameter nor a valid
|
| 281 |
bearer token provides an authenticated user.
|
| 282 |
-
HTTPException: 400 if the `format` parameter is not "md" or "
|
| 283 |
HTTPException: 404 if the document does not exist or does not belong
|
| 284 |
to the user, or if no chat messages are found for the document.
|
| 285 |
"""
|
| 286 |
-
from fastapi import Request
|
| 287 |
from app.auth import decode_token as _decode
|
| 288 |
|
| 289 |
# Resolve user from query-param token (browser download links can't set headers)
|
|
@@ -296,8 +302,8 @@ def export_chat_history(
|
|
| 296 |
if resolved_user is None:
|
| 297 |
raise HTTPException(status_code=401, detail="Authentication required")
|
| 298 |
|
| 299 |
-
if format not in ("md", "txt"):
|
| 300 |
-
raise HTTPException(status_code=400, detail="Format must be 'md' or '
|
| 301 |
|
| 302 |
# Verify document exists and belongs to user
|
| 303 |
doc = db.query(Document).filter(
|
|
@@ -325,15 +331,18 @@ def export_chat_history(
|
|
| 325 |
content = _format_markdown(doc, messages)
|
| 326 |
media_type = "text/markdown"
|
| 327 |
extension = "md"
|
| 328 |
-
|
| 329 |
content = _format_plaintext(doc, messages)
|
| 330 |
media_type = "text/plain"
|
| 331 |
extension = "txt"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
safe_name = doc.original_name.rsplit(".", 1)[0]
|
| 334 |
filename = f"{safe_name}_chat_history.{extension}"
|
| 335 |
|
| 336 |
-
from fastapi.responses import Response
|
| 337 |
return Response(
|
| 338 |
content=content,
|
| 339 |
media_type=media_type,
|
|
@@ -532,3 +541,80 @@ def _format_plaintext(doc, messages) -> str:
|
|
| 532 |
|
| 533 |
return "\n".join(lines)
|
| 534 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Chat routes — ask questions with RAG, stream responses via SSE, manage history.
|
| 3 |
"""
|
| 4 |
+
import html
|
| 5 |
import json
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from io import BytesIO
|
| 8 |
import logging
|
| 9 |
from typing import Optional
|
| 10 |
|
| 11 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 12 |
+
from fastapi.responses import Response, StreamingResponse
|
| 13 |
+
from reportlab.lib.pagesizes import letter
|
| 14 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 15 |
+
from reportlab.lib.units import inch
|
| 16 |
+
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
| 17 |
from sqlalchemy.orm import Session
|
| 18 |
|
| 19 |
from app.database import get_db
|
|
|
|
| 265 |
):
|
| 266 |
"""Export the chat history for a document as a downloadable file.
|
| 267 |
|
| 268 |
+
Supports Markdown (.md), plain text (.txt), or PDF (.pdf) export. The function accepts
|
| 269 |
authentication via either the standard `Authorization: Bearer <token>`
|
| 270 |
header (handled by the dependency chain) or a `token` query parameter to
|
| 271 |
facilitate browser-initiated downloads that cannot set custom headers.
|
| 272 |
|
| 273 |
Args:
|
| 274 |
document_id: The unique identifier of the document whose chat history is to be exported.
|
| 275 |
+
format: Output format, either "md" (Markdown), "txt" (plain text), or "pdf". Defaults to "md".
|
| 276 |
token: Optional JWT token passed as a query parameter. Used for browser
|
| 277 |
downloads when the `Authorization` header is not available.
|
| 278 |
db: SQLAlchemy database session, obtained from the dependency.
|
| 279 |
|
| 280 |
Returns:
|
| 281 |
Response: A FastAPI `Response` object with:
|
| 282 |
+
- `content`: Formatted chat history as a string or PDF bytes.
|
| 283 |
+
- `media_type`: `text/markdown`, `text/plain`, or `application/pdf`.
|
| 284 |
- `headers`: `Content-Disposition` attachment header with a generated filename.
|
| 285 |
|
| 286 |
Raises:
|
| 287 |
HTTPException: 401 if neither the token query parameter nor a valid
|
| 288 |
bearer token provides an authenticated user.
|
| 289 |
+
HTTPException: 400 if the `format` parameter is not "md", "txt", or "pdf".
|
| 290 |
HTTPException: 404 if the document does not exist or does not belong
|
| 291 |
to the user, or if no chat messages are found for the document.
|
| 292 |
"""
|
|
|
|
| 293 |
from app.auth import decode_token as _decode
|
| 294 |
|
| 295 |
# Resolve user from query-param token (browser download links can't set headers)
|
|
|
|
| 302 |
if resolved_user is None:
|
| 303 |
raise HTTPException(status_code=401, detail="Authentication required")
|
| 304 |
|
| 305 |
+
if format not in ("md", "txt", "pdf"):
|
| 306 |
+
raise HTTPException(status_code=400, detail="Format must be 'md', 'txt', or 'pdf'")
|
| 307 |
|
| 308 |
# Verify document exists and belongs to user
|
| 309 |
doc = db.query(Document).filter(
|
|
|
|
| 331 |
content = _format_markdown(doc, messages)
|
| 332 |
media_type = "text/markdown"
|
| 333 |
extension = "md"
|
| 334 |
+
elif format == "txt":
|
| 335 |
content = _format_plaintext(doc, messages)
|
| 336 |
media_type = "text/plain"
|
| 337 |
extension = "txt"
|
| 338 |
+
else:
|
| 339 |
+
content = _format_pdf(doc, messages)
|
| 340 |
+
media_type = "application/pdf"
|
| 341 |
+
extension = "pdf"
|
| 342 |
|
| 343 |
safe_name = doc.original_name.rsplit(".", 1)[0]
|
| 344 |
filename = f"{safe_name}_chat_history.{extension}"
|
| 345 |
|
|
|
|
| 346 |
return Response(
|
| 347 |
content=content,
|
| 348 |
media_type=media_type,
|
|
|
|
| 541 |
|
| 542 |
return "\n".join(lines)
|
| 543 |
|
| 544 |
+
|
| 545 |
+
def _format_pdf(doc, messages) -> bytes:
|
| 546 |
+
"""Format chat history as a PDF document."""
|
| 547 |
+
buffer = BytesIO()
|
| 548 |
+
pdf = SimpleDocTemplate(
|
| 549 |
+
buffer,
|
| 550 |
+
pagesize=letter,
|
| 551 |
+
leftMargin=0.75 * inch,
|
| 552 |
+
rightMargin=0.75 * inch,
|
| 553 |
+
topMargin=0.75 * inch,
|
| 554 |
+
bottomMargin=0.75 * inch,
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
styles = getSampleStyleSheet()
|
| 558 |
+
metadata_style = styles["Normal"]
|
| 559 |
+
metadata_style.spaceAfter = 6
|
| 560 |
+
content_style = ParagraphStyle(
|
| 561 |
+
"ChatContent",
|
| 562 |
+
parent=styles["BodyText"],
|
| 563 |
+
leading=14,
|
| 564 |
+
spaceAfter=10,
|
| 565 |
+
)
|
| 566 |
+
source_style = ParagraphStyle(
|
| 567 |
+
"ChatSource",
|
| 568 |
+
parent=styles["BodyText"],
|
| 569 |
+
leftIndent=14,
|
| 570 |
+
leading=12,
|
| 571 |
+
spaceAfter=4,
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
story = [
|
| 575 |
+
Paragraph(f"Chat History - {html.escape(doc.original_name)}", styles["Title"]),
|
| 576 |
+
Spacer(1, 0.15 * inch),
|
| 577 |
+
Paragraph(f"Document: {html.escape(doc.original_name)}", metadata_style),
|
| 578 |
+
Paragraph(f"Exported at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", metadata_style),
|
| 579 |
+
Paragraph(f"Total messages: {len(messages)}", metadata_style),
|
| 580 |
+
Spacer(1, 0.2 * inch),
|
| 581 |
+
]
|
| 582 |
+
|
| 583 |
+
for msg in messages:
|
| 584 |
+
timestamp = msg.created_at.strftime("%Y-%m-%d %H:%M:%S") if msg.created_at else ""
|
| 585 |
+
role_label = "You" if msg.role == "user" else "Assistant"
|
| 586 |
+
|
| 587 |
+
story.append(Paragraph(f"<b>{html.escape(role_label)}</b>", styles["Heading3"]))
|
| 588 |
+
story.append(Paragraph(html.escape(timestamp), styles["Italic"]))
|
| 589 |
+
story.append(Paragraph(_pdf_text(msg.content), content_style))
|
| 590 |
+
|
| 591 |
+
if msg.role == "assistant" and msg.sources_json:
|
| 592 |
+
try:
|
| 593 |
+
sources = json.loads(msg.sources_json)
|
| 594 |
+
if sources:
|
| 595 |
+
story.append(Paragraph("<b>Sources:</b>", metadata_style))
|
| 596 |
+
for i, src in enumerate(sources, 1):
|
| 597 |
+
filename = html.escape(str(src.get("filename", "Unknown")))
|
| 598 |
+
page = html.escape(str(src.get("page", "?")))
|
| 599 |
+
confidence = html.escape(str(src.get("confidence", 0)))
|
| 600 |
+
story.append(
|
| 601 |
+
Paragraph(
|
| 602 |
+
f"[{i}] {filename}, Page {page} (Confidence: {confidence}%)",
|
| 603 |
+
source_style,
|
| 604 |
+
)
|
| 605 |
+
)
|
| 606 |
+
text_preview = str(src.get("text", "")).strip()
|
| 607 |
+
if text_preview:
|
| 608 |
+
story.append(Paragraph(_pdf_text(text_preview), source_style))
|
| 609 |
+
except Exception:
|
| 610 |
+
pass
|
| 611 |
+
|
| 612 |
+
story.append(Spacer(1, 0.15 * inch))
|
| 613 |
+
|
| 614 |
+
pdf.build(story)
|
| 615 |
+
return buffer.getvalue()
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
def _pdf_text(text: str) -> str:
|
| 619 |
+
"""Escape text for ReportLab paragraphs while preserving line breaks."""
|
| 620 |
+
return html.escape(text or "").replace("\n", "<br/>")
|
backend/app/routes/documents.py
CHANGED
|
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
|
|
| 16 |
|
| 17 |
from app.database import get_db
|
| 18 |
from app.models import User, Document
|
| 19 |
-
from app.schemas import DocumentResponse, DocumentListResponse
|
| 20 |
from app.auth import get_current_user
|
| 21 |
from app.config import get_settings
|
| 22 |
from app.rag.chunker import chunk_document, get_page_count
|
|
@@ -191,7 +191,7 @@ def _ingest_document(document_id: str, filepath: str, original_name: str, user_i
|
|
| 191 |
db.close()
|
| 192 |
|
| 193 |
|
| 194 |
-
@router.post("/upload", response_model=DocumentResponse, status_code=status.
|
| 195 |
async def upload_document(
|
| 196 |
background_tasks: BackgroundTasks,
|
| 197 |
file: UploadFile = File(...),
|
|
@@ -199,11 +199,13 @@ async def upload_document(
|
|
| 199 |
db: Session = Depends(get_db),
|
| 200 |
):
|
| 201 |
"""
|
| 202 |
-
Upload a document
|
| 203 |
|
| 204 |
Validates the uploaded file (extension, size, MIME type, integrity),
|
| 205 |
saves it to the user's directory, creates a database record with status
|
| 206 |
-
'pending',
|
|
|
|
|
|
|
| 207 |
|
| 208 |
Args:
|
| 209 |
background_tasks: FastAPI BackgroundTasks instance to run the ingestion process asynchronously.
|
|
@@ -272,6 +274,30 @@ async def upload_document(
|
|
| 272 |
return DocumentResponse.model_validate(document)
|
| 273 |
|
| 274 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
@router.get("/", response_model=DocumentListResponse)
|
| 276 |
def list_documents(
|
| 277 |
page: int = Query(1, ge=1),
|
|
|
|
| 16 |
|
| 17 |
from app.database import get_db
|
| 18 |
from app.models import User, Document
|
| 19 |
+
from app.schemas import DocumentResponse, DocumentListResponse, DocumentStatusResponse
|
| 20 |
from app.auth import get_current_user
|
| 21 |
from app.config import get_settings
|
| 22 |
from app.rag.chunker import chunk_document, get_page_count
|
|
|
|
| 191 |
db.close()
|
| 192 |
|
| 193 |
|
| 194 |
+
@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_202_ACCEPTED)
|
| 195 |
async def upload_document(
|
| 196 |
background_tasks: BackgroundTasks,
|
| 197 |
file: UploadFile = File(...),
|
|
|
|
| 199 |
db: Session = Depends(get_db),
|
| 200 |
):
|
| 201 |
"""
|
| 202 |
+
Upload a document and enqueue RAG processing.
|
| 203 |
|
| 204 |
Validates the uploaded file (extension, size, MIME type, integrity),
|
| 205 |
saves it to the user's directory, creates a database record with status
|
| 206 |
+
'pending', schedules a background task for chunking and embedding, and
|
| 207 |
+
returns 202 Accepted immediately so large documents do not block the API
|
| 208 |
+
request while embeddings are generated.
|
| 209 |
|
| 210 |
Args:
|
| 211 |
background_tasks: FastAPI BackgroundTasks instance to run the ingestion process asynchronously.
|
|
|
|
| 274 |
return DocumentResponse.model_validate(document)
|
| 275 |
|
| 276 |
|
| 277 |
+
@router.get("/{document_id}/status", response_model=DocumentStatusResponse)
|
| 278 |
+
def get_document_status(
|
| 279 |
+
document_id: str,
|
| 280 |
+
user: User = Depends(get_current_user),
|
| 281 |
+
db: Session = Depends(get_db),
|
| 282 |
+
):
|
| 283 |
+
"""
|
| 284 |
+
Poll processing status for a single uploaded document.
|
| 285 |
+
|
| 286 |
+
This endpoint lets clients refresh the upload lifecycle without fetching
|
| 287 |
+
the entire document list. The returned status is one of the existing
|
| 288 |
+
document states: pending, processing, ready, or failed.
|
| 289 |
+
"""
|
| 290 |
+
doc = db.query(Document).filter(
|
| 291 |
+
Document.id == document_id,
|
| 292 |
+
Document.user_id == user.id,
|
| 293 |
+
).first()
|
| 294 |
+
|
| 295 |
+
if not doc:
|
| 296 |
+
raise HTTPException(status_code=404, detail="Document not found")
|
| 297 |
+
|
| 298 |
+
return DocumentStatusResponse.model_validate(doc)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
@router.get("/", response_model=DocumentListResponse)
|
| 302 |
def list_documents(
|
| 303 |
page: int = Query(1, ge=1),
|
backend/app/schemas.py
CHANGED
|
@@ -75,6 +75,17 @@ class DocumentResponse(BaseModel):
|
|
| 75 |
from_attributes = True
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
class DocumentListResponse(BaseModel):
|
| 79 |
items: List[DocumentResponse]
|
| 80 |
total: int
|
|
|
|
| 75 |
from_attributes = True
|
| 76 |
|
| 77 |
|
| 78 |
+
class DocumentStatusResponse(BaseModel):
|
| 79 |
+
id: str
|
| 80 |
+
status: str
|
| 81 |
+
page_count: int
|
| 82 |
+
chunk_count: int
|
| 83 |
+
error_message: Optional[str] = None
|
| 84 |
+
|
| 85 |
+
class Config:
|
| 86 |
+
from_attributes = True
|
| 87 |
+
|
| 88 |
+
|
| 89 |
class DocumentListResponse(BaseModel):
|
| 90 |
items: List[DocumentResponse]
|
| 91 |
total: int
|
backend/requirements.txt
CHANGED
|
@@ -49,3 +49,4 @@ python-magic-bin==0.4.27; sys_platform == "win32" # for windows
|
|
| 49 |
python-magic; sys_platform != "win32"
|
| 50 |
python-docx
|
| 51 |
pypdf
|
|
|
|
|
|
| 49 |
python-magic; sys_platform != "win32"
|
| 50 |
python-docx
|
| 51 |
pypdf
|
| 52 |
+
reportlab
|
backend/scripts/vector_cleanup.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cleanup script for ChromaDB vectors belonging to inactive users.
|
| 3 |
+
|
| 4 |
+
By default, a user is considered inactive if they have not logged in for
|
| 5 |
+
30 days. Users without last_login are skipped to avoid deleting vectors for
|
| 6 |
+
legacy accounts before activity tracking existed.
|
| 7 |
+
|
| 8 |
+
Run manually:
|
| 9 |
+
python backend/scripts/vector_cleanup.py
|
| 10 |
+
|
| 11 |
+
Environment:
|
| 12 |
+
VECTOR_CLEANUP_INACTIVE_DAYS=30
|
| 13 |
+
VECTOR_CLEANUP_DRY_RUN=true
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from datetime import datetime, timedelta, timezone
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
from sqlalchemy import inspect, or_, text
|
| 24 |
+
|
| 25 |
+
# Allow running this file directly from the repository root.
|
| 26 |
+
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
| 27 |
+
if str(BACKEND_DIR) not in sys.path:
|
| 28 |
+
sys.path.insert(0, str(BACKEND_DIR))
|
| 29 |
+
|
| 30 |
+
from app.database import SessionLocal # noqa: E402
|
| 31 |
+
from app.models import User # noqa: E402
|
| 32 |
+
from app.rag.vectorstore import delete_user_collection # noqa: E402
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger("vector_cleanup")
|
| 35 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _env_bool(name: str, default: bool = False) -> bool:
|
| 39 |
+
value = os.getenv(name)
|
| 40 |
+
if value is None:
|
| 41 |
+
return default
|
| 42 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def ensure_last_login_column() -> None:
|
| 47 |
+
"""Add users.last_login for SQLite installs that do not run migrations."""
|
| 48 |
+
db = SessionLocal()
|
| 49 |
+
try:
|
| 50 |
+
bind = db.get_bind()
|
| 51 |
+
inspector = inspect(bind)
|
| 52 |
+
columns = {column["name"] for column in inspector.get_columns("users")}
|
| 53 |
+
if "last_login" not in columns:
|
| 54 |
+
logger.info("Adding missing users.last_login column")
|
| 55 |
+
db.execute(text("ALTER TABLE users ADD COLUMN last_login DATETIME"))
|
| 56 |
+
db.commit()
|
| 57 |
+
finally:
|
| 58 |
+
db.close()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def cleanup_inactive_user_vectors(
|
| 62 |
+
inactive_days: int | None = None,
|
| 63 |
+
dry_run: bool | None = None,
|
| 64 |
+
) -> dict[str, int]:
|
| 65 |
+
"""Delete Chroma collections for users inactive past the threshold."""
|
| 66 |
+
ensure_last_login_column()
|
| 67 |
+
|
| 68 |
+
days = inactive_days or int(os.getenv("VECTOR_CLEANUP_INACTIVE_DAYS", "30"))
|
| 69 |
+
is_dry_run = _env_bool("VECTOR_CLEANUP_DRY_RUN", False) if dry_run is None else dry_run
|
| 70 |
+
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
| 71 |
+
|
| 72 |
+
stats = {
|
| 73 |
+
"scanned": 0,
|
| 74 |
+
"eligible": 0,
|
| 75 |
+
"deleted": 0,
|
| 76 |
+
"skipped_no_login": 0,
|
| 77 |
+
"failed": 0,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
db = SessionLocal()
|
| 81 |
+
try:
|
| 82 |
+
users = db.query(User).filter(
|
| 83 |
+
or_(User.last_login.is_(None), User.last_login < cutoff)
|
| 84 |
+
).all()
|
| 85 |
+
|
| 86 |
+
for user in users:
|
| 87 |
+
stats["scanned"] += 1
|
| 88 |
+
|
| 89 |
+
if user.last_login is None:
|
| 90 |
+
stats["skipped_no_login"] += 1
|
| 91 |
+
logger.info(
|
| 92 |
+
"Skipping user %s because last_login is missing",
|
| 93 |
+
user.id,
|
| 94 |
+
)
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
stats["eligible"] += 1
|
| 98 |
+
logger.info(
|
| 99 |
+
"User %s inactive since %s; deleting collection=%s dry_run=%s",
|
| 100 |
+
user.id,
|
| 101 |
+
user.last_login,
|
| 102 |
+
f"user_{user.id.replace('-', '_')}"[:63],
|
| 103 |
+
is_dry_run,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if is_dry_run:
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
delete_user_collection(user.id)
|
| 111 |
+
stats["deleted"] += 1
|
| 112 |
+
except Exception as exc: # defensive script boundary
|
| 113 |
+
stats["failed"] += 1
|
| 114 |
+
logger.warning(
|
| 115 |
+
"Failed deleting vector collection for user %s: %s",
|
| 116 |
+
user.id,
|
| 117 |
+
exc,
|
| 118 |
+
exc_info=True,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
logger.info("Vector cleanup complete: %s", stats)
|
| 122 |
+
return stats
|
| 123 |
+
finally:
|
| 124 |
+
db.close()
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
cleanup_inactive_user_vectors()
|
frontend/package-lock.json
CHANGED
|
@@ -22,7 +22,8 @@
|
|
| 22 |
"remark-gfm": "^4.0.1",
|
| 23 |
"shadcn": "^4.3.1",
|
| 24 |
"tailwind-merge": "^3.5.0",
|
| 25 |
-
"tw-animate-css": "^1.4.0"
|
|
|
|
| 26 |
},
|
| 27 |
"devDependencies": {
|
| 28 |
"@tailwindcss/postcss": "^4",
|
|
@@ -1106,9 +1107,6 @@
|
|
| 1106 |
"cpu": [
|
| 1107 |
"arm"
|
| 1108 |
],
|
| 1109 |
-
"libc": [
|
| 1110 |
-
"glibc"
|
| 1111 |
-
],
|
| 1112 |
"license": "LGPL-3.0-or-later",
|
| 1113 |
"optional": true,
|
| 1114 |
"os": [
|
|
@@ -1125,9 +1123,6 @@
|
|
| 1125 |
"cpu": [
|
| 1126 |
"arm64"
|
| 1127 |
],
|
| 1128 |
-
"libc": [
|
| 1129 |
-
"glibc"
|
| 1130 |
-
],
|
| 1131 |
"license": "LGPL-3.0-or-later",
|
| 1132 |
"optional": true,
|
| 1133 |
"os": [
|
|
@@ -1144,9 +1139,6 @@
|
|
| 1144 |
"cpu": [
|
| 1145 |
"ppc64"
|
| 1146 |
],
|
| 1147 |
-
"libc": [
|
| 1148 |
-
"glibc"
|
| 1149 |
-
],
|
| 1150 |
"license": "LGPL-3.0-or-later",
|
| 1151 |
"optional": true,
|
| 1152 |
"os": [
|
|
@@ -1163,9 +1155,6 @@
|
|
| 1163 |
"cpu": [
|
| 1164 |
"riscv64"
|
| 1165 |
],
|
| 1166 |
-
"libc": [
|
| 1167 |
-
"glibc"
|
| 1168 |
-
],
|
| 1169 |
"license": "LGPL-3.0-or-later",
|
| 1170 |
"optional": true,
|
| 1171 |
"os": [
|
|
@@ -1182,9 +1171,6 @@
|
|
| 1182 |
"cpu": [
|
| 1183 |
"s390x"
|
| 1184 |
],
|
| 1185 |
-
"libc": [
|
| 1186 |
-
"glibc"
|
| 1187 |
-
],
|
| 1188 |
"license": "LGPL-3.0-or-later",
|
| 1189 |
"optional": true,
|
| 1190 |
"os": [
|
|
@@ -1201,9 +1187,6 @@
|
|
| 1201 |
"cpu": [
|
| 1202 |
"x64"
|
| 1203 |
],
|
| 1204 |
-
"libc": [
|
| 1205 |
-
"glibc"
|
| 1206 |
-
],
|
| 1207 |
"license": "LGPL-3.0-or-later",
|
| 1208 |
"optional": true,
|
| 1209 |
"os": [
|
|
@@ -1220,9 +1203,6 @@
|
|
| 1220 |
"cpu": [
|
| 1221 |
"arm64"
|
| 1222 |
],
|
| 1223 |
-
"libc": [
|
| 1224 |
-
"musl"
|
| 1225 |
-
],
|
| 1226 |
"license": "LGPL-3.0-or-later",
|
| 1227 |
"optional": true,
|
| 1228 |
"os": [
|
|
@@ -1239,9 +1219,6 @@
|
|
| 1239 |
"cpu": [
|
| 1240 |
"x64"
|
| 1241 |
],
|
| 1242 |
-
"libc": [
|
| 1243 |
-
"musl"
|
| 1244 |
-
],
|
| 1245 |
"license": "LGPL-3.0-or-later",
|
| 1246 |
"optional": true,
|
| 1247 |
"os": [
|
|
@@ -1258,9 +1235,6 @@
|
|
| 1258 |
"cpu": [
|
| 1259 |
"arm"
|
| 1260 |
],
|
| 1261 |
-
"libc": [
|
| 1262 |
-
"glibc"
|
| 1263 |
-
],
|
| 1264 |
"license": "Apache-2.0",
|
| 1265 |
"optional": true,
|
| 1266 |
"os": [
|
|
@@ -1283,9 +1257,6 @@
|
|
| 1283 |
"cpu": [
|
| 1284 |
"arm64"
|
| 1285 |
],
|
| 1286 |
-
"libc": [
|
| 1287 |
-
"glibc"
|
| 1288 |
-
],
|
| 1289 |
"license": "Apache-2.0",
|
| 1290 |
"optional": true,
|
| 1291 |
"os": [
|
|
@@ -1308,9 +1279,6 @@
|
|
| 1308 |
"cpu": [
|
| 1309 |
"ppc64"
|
| 1310 |
],
|
| 1311 |
-
"libc": [
|
| 1312 |
-
"glibc"
|
| 1313 |
-
],
|
| 1314 |
"license": "Apache-2.0",
|
| 1315 |
"optional": true,
|
| 1316 |
"os": [
|
|
@@ -1333,9 +1301,6 @@
|
|
| 1333 |
"cpu": [
|
| 1334 |
"riscv64"
|
| 1335 |
],
|
| 1336 |
-
"libc": [
|
| 1337 |
-
"glibc"
|
| 1338 |
-
],
|
| 1339 |
"license": "Apache-2.0",
|
| 1340 |
"optional": true,
|
| 1341 |
"os": [
|
|
@@ -1358,9 +1323,6 @@
|
|
| 1358 |
"cpu": [
|
| 1359 |
"s390x"
|
| 1360 |
],
|
| 1361 |
-
"libc": [
|
| 1362 |
-
"glibc"
|
| 1363 |
-
],
|
| 1364 |
"license": "Apache-2.0",
|
| 1365 |
"optional": true,
|
| 1366 |
"os": [
|
|
@@ -1383,9 +1345,6 @@
|
|
| 1383 |
"cpu": [
|
| 1384 |
"x64"
|
| 1385 |
],
|
| 1386 |
-
"libc": [
|
| 1387 |
-
"glibc"
|
| 1388 |
-
],
|
| 1389 |
"license": "Apache-2.0",
|
| 1390 |
"optional": true,
|
| 1391 |
"os": [
|
|
@@ -1408,9 +1367,6 @@
|
|
| 1408 |
"cpu": [
|
| 1409 |
"arm64"
|
| 1410 |
],
|
| 1411 |
-
"libc": [
|
| 1412 |
-
"musl"
|
| 1413 |
-
],
|
| 1414 |
"license": "Apache-2.0",
|
| 1415 |
"optional": true,
|
| 1416 |
"os": [
|
|
@@ -1433,9 +1389,6 @@
|
|
| 1433 |
"cpu": [
|
| 1434 |
"x64"
|
| 1435 |
],
|
| 1436 |
-
"libc": [
|
| 1437 |
-
"musl"
|
| 1438 |
-
],
|
| 1439 |
"license": "Apache-2.0",
|
| 1440 |
"optional": true,
|
| 1441 |
"os": [
|
|
@@ -1856,9 +1809,6 @@
|
|
| 1856 |
"cpu": [
|
| 1857 |
"arm64"
|
| 1858 |
],
|
| 1859 |
-
"libc": [
|
| 1860 |
-
"glibc"
|
| 1861 |
-
],
|
| 1862 |
"license": "MIT",
|
| 1863 |
"optional": true,
|
| 1864 |
"os": [
|
|
@@ -1879,9 +1829,6 @@
|
|
| 1879 |
"cpu": [
|
| 1880 |
"arm64"
|
| 1881 |
],
|
| 1882 |
-
"libc": [
|
| 1883 |
-
"musl"
|
| 1884 |
-
],
|
| 1885 |
"license": "MIT",
|
| 1886 |
"optional": true,
|
| 1887 |
"os": [
|
|
@@ -1902,9 +1849,6 @@
|
|
| 1902 |
"cpu": [
|
| 1903 |
"riscv64"
|
| 1904 |
],
|
| 1905 |
-
"libc": [
|
| 1906 |
-
"glibc"
|
| 1907 |
-
],
|
| 1908 |
"license": "MIT",
|
| 1909 |
"optional": true,
|
| 1910 |
"os": [
|
|
@@ -1925,9 +1869,6 @@
|
|
| 1925 |
"cpu": [
|
| 1926 |
"x64"
|
| 1927 |
],
|
| 1928 |
-
"libc": [
|
| 1929 |
-
"glibc"
|
| 1930 |
-
],
|
| 1931 |
"license": "MIT",
|
| 1932 |
"optional": true,
|
| 1933 |
"os": [
|
|
@@ -1948,9 +1889,6 @@
|
|
| 1948 |
"cpu": [
|
| 1949 |
"x64"
|
| 1950 |
],
|
| 1951 |
-
"libc": [
|
| 1952 |
-
"musl"
|
| 1953 |
-
],
|
| 1954 |
"license": "MIT",
|
| 1955 |
"optional": true,
|
| 1956 |
"os": [
|
|
@@ -2072,9 +2010,6 @@
|
|
| 2072 |
"cpu": [
|
| 2073 |
"arm64"
|
| 2074 |
],
|
| 2075 |
-
"libc": [
|
| 2076 |
-
"glibc"
|
| 2077 |
-
],
|
| 2078 |
"license": "MIT",
|
| 2079 |
"optional": true,
|
| 2080 |
"os": [
|
|
@@ -2091,9 +2026,6 @@
|
|
| 2091 |
"cpu": [
|
| 2092 |
"arm64"
|
| 2093 |
],
|
| 2094 |
-
"libc": [
|
| 2095 |
-
"musl"
|
| 2096 |
-
],
|
| 2097 |
"license": "MIT",
|
| 2098 |
"optional": true,
|
| 2099 |
"os": [
|
|
@@ -2110,9 +2042,6 @@
|
|
| 2110 |
"cpu": [
|
| 2111 |
"x64"
|
| 2112 |
],
|
| 2113 |
-
"libc": [
|
| 2114 |
-
"glibc"
|
| 2115 |
-
],
|
| 2116 |
"license": "MIT",
|
| 2117 |
"optional": true,
|
| 2118 |
"os": [
|
|
@@ -2129,9 +2058,6 @@
|
|
| 2129 |
"cpu": [
|
| 2130 |
"x64"
|
| 2131 |
],
|
| 2132 |
-
"libc": [
|
| 2133 |
-
"musl"
|
| 2134 |
-
],
|
| 2135 |
"license": "MIT",
|
| 2136 |
"optional": true,
|
| 2137 |
"os": [
|
|
@@ -2446,9 +2372,6 @@
|
|
| 2446 |
"arm64"
|
| 2447 |
],
|
| 2448 |
"dev": true,
|
| 2449 |
-
"libc": [
|
| 2450 |
-
"glibc"
|
| 2451 |
-
],
|
| 2452 |
"license": "MIT",
|
| 2453 |
"optional": true,
|
| 2454 |
"os": [
|
|
@@ -2466,9 +2389,6 @@
|
|
| 2466 |
"arm64"
|
| 2467 |
],
|
| 2468 |
"dev": true,
|
| 2469 |
-
"libc": [
|
| 2470 |
-
"musl"
|
| 2471 |
-
],
|
| 2472 |
"license": "MIT",
|
| 2473 |
"optional": true,
|
| 2474 |
"os": [
|
|
@@ -2486,9 +2406,6 @@
|
|
| 2486 |
"x64"
|
| 2487 |
],
|
| 2488 |
"dev": true,
|
| 2489 |
-
"libc": [
|
| 2490 |
-
"glibc"
|
| 2491 |
-
],
|
| 2492 |
"license": "MIT",
|
| 2493 |
"optional": true,
|
| 2494 |
"os": [
|
|
@@ -2506,9 +2423,6 @@
|
|
| 2506 |
"x64"
|
| 2507 |
],
|
| 2508 |
"dev": true,
|
| 2509 |
-
"libc": [
|
| 2510 |
-
"musl"
|
| 2511 |
-
],
|
| 2512 |
"license": "MIT",
|
| 2513 |
"optional": true,
|
| 2514 |
"os": [
|
|
@@ -3206,9 +3120,6 @@
|
|
| 3206 |
"arm64"
|
| 3207 |
],
|
| 3208 |
"dev": true,
|
| 3209 |
-
"libc": [
|
| 3210 |
-
"glibc"
|
| 3211 |
-
],
|
| 3212 |
"license": "MIT",
|
| 3213 |
"optional": true,
|
| 3214 |
"os": [
|
|
@@ -3223,9 +3134,6 @@
|
|
| 3223 |
"arm64"
|
| 3224 |
],
|
| 3225 |
"dev": true,
|
| 3226 |
-
"libc": [
|
| 3227 |
-
"musl"
|
| 3228 |
-
],
|
| 3229 |
"license": "MIT",
|
| 3230 |
"optional": true,
|
| 3231 |
"os": [
|
|
@@ -3240,9 +3148,6 @@
|
|
| 3240 |
"ppc64"
|
| 3241 |
],
|
| 3242 |
"dev": true,
|
| 3243 |
-
"libc": [
|
| 3244 |
-
"glibc"
|
| 3245 |
-
],
|
| 3246 |
"license": "MIT",
|
| 3247 |
"optional": true,
|
| 3248 |
"os": [
|
|
@@ -3257,9 +3162,6 @@
|
|
| 3257 |
"riscv64"
|
| 3258 |
],
|
| 3259 |
"dev": true,
|
| 3260 |
-
"libc": [
|
| 3261 |
-
"glibc"
|
| 3262 |
-
],
|
| 3263 |
"license": "MIT",
|
| 3264 |
"optional": true,
|
| 3265 |
"os": [
|
|
@@ -3274,9 +3176,6 @@
|
|
| 3274 |
"riscv64"
|
| 3275 |
],
|
| 3276 |
"dev": true,
|
| 3277 |
-
"libc": [
|
| 3278 |
-
"musl"
|
| 3279 |
-
],
|
| 3280 |
"license": "MIT",
|
| 3281 |
"optional": true,
|
| 3282 |
"os": [
|
|
@@ -3291,9 +3190,6 @@
|
|
| 3291 |
"s390x"
|
| 3292 |
],
|
| 3293 |
"dev": true,
|
| 3294 |
-
"libc": [
|
| 3295 |
-
"glibc"
|
| 3296 |
-
],
|
| 3297 |
"license": "MIT",
|
| 3298 |
"optional": true,
|
| 3299 |
"os": [
|
|
@@ -3308,9 +3204,6 @@
|
|
| 3308 |
"x64"
|
| 3309 |
],
|
| 3310 |
"dev": true,
|
| 3311 |
-
"libc": [
|
| 3312 |
-
"glibc"
|
| 3313 |
-
],
|
| 3314 |
"license": "MIT",
|
| 3315 |
"optional": true,
|
| 3316 |
"os": [
|
|
@@ -3325,9 +3218,6 @@
|
|
| 3325 |
"x64"
|
| 3326 |
],
|
| 3327 |
"dev": true,
|
| 3328 |
-
"libc": [
|
| 3329 |
-
"musl"
|
| 3330 |
-
],
|
| 3331 |
"license": "MIT",
|
| 3332 |
"optional": true,
|
| 3333 |
"os": [
|
|
@@ -7288,9 +7178,6 @@
|
|
| 7288 |
"arm64"
|
| 7289 |
],
|
| 7290 |
"dev": true,
|
| 7291 |
-
"libc": [
|
| 7292 |
-
"glibc"
|
| 7293 |
-
],
|
| 7294 |
"license": "MPL-2.0",
|
| 7295 |
"optional": true,
|
| 7296 |
"os": [
|
|
@@ -7312,9 +7199,6 @@
|
|
| 7312 |
"arm64"
|
| 7313 |
],
|
| 7314 |
"dev": true,
|
| 7315 |
-
"libc": [
|
| 7316 |
-
"musl"
|
| 7317 |
-
],
|
| 7318 |
"license": "MPL-2.0",
|
| 7319 |
"optional": true,
|
| 7320 |
"os": [
|
|
@@ -7336,9 +7220,6 @@
|
|
| 7336 |
"x64"
|
| 7337 |
],
|
| 7338 |
"dev": true,
|
| 7339 |
-
"libc": [
|
| 7340 |
-
"glibc"
|
| 7341 |
-
],
|
| 7342 |
"license": "MPL-2.0",
|
| 7343 |
"optional": true,
|
| 7344 |
"os": [
|
|
@@ -7360,9 +7241,6 @@
|
|
| 7360 |
"x64"
|
| 7361 |
],
|
| 7362 |
"dev": true,
|
| 7363 |
-
"libc": [
|
| 7364 |
-
"musl"
|
| 7365 |
-
],
|
| 7366 |
"license": "MPL-2.0",
|
| 7367 |
"optional": true,
|
| 7368 |
"os": [
|
|
@@ -11763,6 +11641,35 @@
|
|
| 11763 |
"zod": "^3.25.0 || ^4.0.0"
|
| 11764 |
}
|
| 11765 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11766 |
"node_modules/zwitch": {
|
| 11767 |
"version": "2.0.4",
|
| 11768 |
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
|
|
|
| 22 |
"remark-gfm": "^4.0.1",
|
| 23 |
"shadcn": "^4.3.1",
|
| 24 |
"tailwind-merge": "^3.5.0",
|
| 25 |
+
"tw-animate-css": "^1.4.0",
|
| 26 |
+
"zustand": "^5.0.13"
|
| 27 |
},
|
| 28 |
"devDependencies": {
|
| 29 |
"@tailwindcss/postcss": "^4",
|
|
|
|
| 1107 |
"cpu": [
|
| 1108 |
"arm"
|
| 1109 |
],
|
|
|
|
|
|
|
|
|
|
| 1110 |
"license": "LGPL-3.0-or-later",
|
| 1111 |
"optional": true,
|
| 1112 |
"os": [
|
|
|
|
| 1123 |
"cpu": [
|
| 1124 |
"arm64"
|
| 1125 |
],
|
|
|
|
|
|
|
|
|
|
| 1126 |
"license": "LGPL-3.0-or-later",
|
| 1127 |
"optional": true,
|
| 1128 |
"os": [
|
|
|
|
| 1139 |
"cpu": [
|
| 1140 |
"ppc64"
|
| 1141 |
],
|
|
|
|
|
|
|
|
|
|
| 1142 |
"license": "LGPL-3.0-or-later",
|
| 1143 |
"optional": true,
|
| 1144 |
"os": [
|
|
|
|
| 1155 |
"cpu": [
|
| 1156 |
"riscv64"
|
| 1157 |
],
|
|
|
|
|
|
|
|
|
|
| 1158 |
"license": "LGPL-3.0-or-later",
|
| 1159 |
"optional": true,
|
| 1160 |
"os": [
|
|
|
|
| 1171 |
"cpu": [
|
| 1172 |
"s390x"
|
| 1173 |
],
|
|
|
|
|
|
|
|
|
|
| 1174 |
"license": "LGPL-3.0-or-later",
|
| 1175 |
"optional": true,
|
| 1176 |
"os": [
|
|
|
|
| 1187 |
"cpu": [
|
| 1188 |
"x64"
|
| 1189 |
],
|
|
|
|
|
|
|
|
|
|
| 1190 |
"license": "LGPL-3.0-or-later",
|
| 1191 |
"optional": true,
|
| 1192 |
"os": [
|
|
|
|
| 1203 |
"cpu": [
|
| 1204 |
"arm64"
|
| 1205 |
],
|
|
|
|
|
|
|
|
|
|
| 1206 |
"license": "LGPL-3.0-or-later",
|
| 1207 |
"optional": true,
|
| 1208 |
"os": [
|
|
|
|
| 1219 |
"cpu": [
|
| 1220 |
"x64"
|
| 1221 |
],
|
|
|
|
|
|
|
|
|
|
| 1222 |
"license": "LGPL-3.0-or-later",
|
| 1223 |
"optional": true,
|
| 1224 |
"os": [
|
|
|
|
| 1235 |
"cpu": [
|
| 1236 |
"arm"
|
| 1237 |
],
|
|
|
|
|
|
|
|
|
|
| 1238 |
"license": "Apache-2.0",
|
| 1239 |
"optional": true,
|
| 1240 |
"os": [
|
|
|
|
| 1257 |
"cpu": [
|
| 1258 |
"arm64"
|
| 1259 |
],
|
|
|
|
|
|
|
|
|
|
| 1260 |
"license": "Apache-2.0",
|
| 1261 |
"optional": true,
|
| 1262 |
"os": [
|
|
|
|
| 1279 |
"cpu": [
|
| 1280 |
"ppc64"
|
| 1281 |
],
|
|
|
|
|
|
|
|
|
|
| 1282 |
"license": "Apache-2.0",
|
| 1283 |
"optional": true,
|
| 1284 |
"os": [
|
|
|
|
| 1301 |
"cpu": [
|
| 1302 |
"riscv64"
|
| 1303 |
],
|
|
|
|
|
|
|
|
|
|
| 1304 |
"license": "Apache-2.0",
|
| 1305 |
"optional": true,
|
| 1306 |
"os": [
|
|
|
|
| 1323 |
"cpu": [
|
| 1324 |
"s390x"
|
| 1325 |
],
|
|
|
|
|
|
|
|
|
|
| 1326 |
"license": "Apache-2.0",
|
| 1327 |
"optional": true,
|
| 1328 |
"os": [
|
|
|
|
| 1345 |
"cpu": [
|
| 1346 |
"x64"
|
| 1347 |
],
|
|
|
|
|
|
|
|
|
|
| 1348 |
"license": "Apache-2.0",
|
| 1349 |
"optional": true,
|
| 1350 |
"os": [
|
|
|
|
| 1367 |
"cpu": [
|
| 1368 |
"arm64"
|
| 1369 |
],
|
|
|
|
|
|
|
|
|
|
| 1370 |
"license": "Apache-2.0",
|
| 1371 |
"optional": true,
|
| 1372 |
"os": [
|
|
|
|
| 1389 |
"cpu": [
|
| 1390 |
"x64"
|
| 1391 |
],
|
|
|
|
|
|
|
|
|
|
| 1392 |
"license": "Apache-2.0",
|
| 1393 |
"optional": true,
|
| 1394 |
"os": [
|
|
|
|
| 1809 |
"cpu": [
|
| 1810 |
"arm64"
|
| 1811 |
],
|
|
|
|
|
|
|
|
|
|
| 1812 |
"license": "MIT",
|
| 1813 |
"optional": true,
|
| 1814 |
"os": [
|
|
|
|
| 1829 |
"cpu": [
|
| 1830 |
"arm64"
|
| 1831 |
],
|
|
|
|
|
|
|
|
|
|
| 1832 |
"license": "MIT",
|
| 1833 |
"optional": true,
|
| 1834 |
"os": [
|
|
|
|
| 1849 |
"cpu": [
|
| 1850 |
"riscv64"
|
| 1851 |
],
|
|
|
|
|
|
|
|
|
|
| 1852 |
"license": "MIT",
|
| 1853 |
"optional": true,
|
| 1854 |
"os": [
|
|
|
|
| 1869 |
"cpu": [
|
| 1870 |
"x64"
|
| 1871 |
],
|
|
|
|
|
|
|
|
|
|
| 1872 |
"license": "MIT",
|
| 1873 |
"optional": true,
|
| 1874 |
"os": [
|
|
|
|
| 1889 |
"cpu": [
|
| 1890 |
"x64"
|
| 1891 |
],
|
|
|
|
|
|
|
|
|
|
| 1892 |
"license": "MIT",
|
| 1893 |
"optional": true,
|
| 1894 |
"os": [
|
|
|
|
| 2010 |
"cpu": [
|
| 2011 |
"arm64"
|
| 2012 |
],
|
|
|
|
|
|
|
|
|
|
| 2013 |
"license": "MIT",
|
| 2014 |
"optional": true,
|
| 2015 |
"os": [
|
|
|
|
| 2026 |
"cpu": [
|
| 2027 |
"arm64"
|
| 2028 |
],
|
|
|
|
|
|
|
|
|
|
| 2029 |
"license": "MIT",
|
| 2030 |
"optional": true,
|
| 2031 |
"os": [
|
|
|
|
| 2042 |
"cpu": [
|
| 2043 |
"x64"
|
| 2044 |
],
|
|
|
|
|
|
|
|
|
|
| 2045 |
"license": "MIT",
|
| 2046 |
"optional": true,
|
| 2047 |
"os": [
|
|
|
|
| 2058 |
"cpu": [
|
| 2059 |
"x64"
|
| 2060 |
],
|
|
|
|
|
|
|
|
|
|
| 2061 |
"license": "MIT",
|
| 2062 |
"optional": true,
|
| 2063 |
"os": [
|
|
|
|
| 2372 |
"arm64"
|
| 2373 |
],
|
| 2374 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2375 |
"license": "MIT",
|
| 2376 |
"optional": true,
|
| 2377 |
"os": [
|
|
|
|
| 2389 |
"arm64"
|
| 2390 |
],
|
| 2391 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2392 |
"license": "MIT",
|
| 2393 |
"optional": true,
|
| 2394 |
"os": [
|
|
|
|
| 2406 |
"x64"
|
| 2407 |
],
|
| 2408 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2409 |
"license": "MIT",
|
| 2410 |
"optional": true,
|
| 2411 |
"os": [
|
|
|
|
| 2423 |
"x64"
|
| 2424 |
],
|
| 2425 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 2426 |
"license": "MIT",
|
| 2427 |
"optional": true,
|
| 2428 |
"os": [
|
|
|
|
| 3120 |
"arm64"
|
| 3121 |
],
|
| 3122 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3123 |
"license": "MIT",
|
| 3124 |
"optional": true,
|
| 3125 |
"os": [
|
|
|
|
| 3134 |
"arm64"
|
| 3135 |
],
|
| 3136 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3137 |
"license": "MIT",
|
| 3138 |
"optional": true,
|
| 3139 |
"os": [
|
|
|
|
| 3148 |
"ppc64"
|
| 3149 |
],
|
| 3150 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3151 |
"license": "MIT",
|
| 3152 |
"optional": true,
|
| 3153 |
"os": [
|
|
|
|
| 3162 |
"riscv64"
|
| 3163 |
],
|
| 3164 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3165 |
"license": "MIT",
|
| 3166 |
"optional": true,
|
| 3167 |
"os": [
|
|
|
|
| 3176 |
"riscv64"
|
| 3177 |
],
|
| 3178 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3179 |
"license": "MIT",
|
| 3180 |
"optional": true,
|
| 3181 |
"os": [
|
|
|
|
| 3190 |
"s390x"
|
| 3191 |
],
|
| 3192 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3193 |
"license": "MIT",
|
| 3194 |
"optional": true,
|
| 3195 |
"os": [
|
|
|
|
| 3204 |
"x64"
|
| 3205 |
],
|
| 3206 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3207 |
"license": "MIT",
|
| 3208 |
"optional": true,
|
| 3209 |
"os": [
|
|
|
|
| 3218 |
"x64"
|
| 3219 |
],
|
| 3220 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 3221 |
"license": "MIT",
|
| 3222 |
"optional": true,
|
| 3223 |
"os": [
|
|
|
|
| 7178 |
"arm64"
|
| 7179 |
],
|
| 7180 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7181 |
"license": "MPL-2.0",
|
| 7182 |
"optional": true,
|
| 7183 |
"os": [
|
|
|
|
| 7199 |
"arm64"
|
| 7200 |
],
|
| 7201 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7202 |
"license": "MPL-2.0",
|
| 7203 |
"optional": true,
|
| 7204 |
"os": [
|
|
|
|
| 7220 |
"x64"
|
| 7221 |
],
|
| 7222 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7223 |
"license": "MPL-2.0",
|
| 7224 |
"optional": true,
|
| 7225 |
"os": [
|
|
|
|
| 7241 |
"x64"
|
| 7242 |
],
|
| 7243 |
"dev": true,
|
|
|
|
|
|
|
|
|
|
| 7244 |
"license": "MPL-2.0",
|
| 7245 |
"optional": true,
|
| 7246 |
"os": [
|
|
|
|
| 11641 |
"zod": "^3.25.0 || ^4.0.0"
|
| 11642 |
}
|
| 11643 |
},
|
| 11644 |
+
"node_modules/zustand": {
|
| 11645 |
+
"version": "5.0.13",
|
| 11646 |
+
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
| 11647 |
+
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
| 11648 |
+
"license": "MIT",
|
| 11649 |
+
"engines": {
|
| 11650 |
+
"node": ">=12.20.0"
|
| 11651 |
+
},
|
| 11652 |
+
"peerDependencies": {
|
| 11653 |
+
"@types/react": ">=18.0.0",
|
| 11654 |
+
"immer": ">=9.0.6",
|
| 11655 |
+
"react": ">=18.0.0",
|
| 11656 |
+
"use-sync-external-store": ">=1.2.0"
|
| 11657 |
+
},
|
| 11658 |
+
"peerDependenciesMeta": {
|
| 11659 |
+
"@types/react": {
|
| 11660 |
+
"optional": true
|
| 11661 |
+
},
|
| 11662 |
+
"immer": {
|
| 11663 |
+
"optional": true
|
| 11664 |
+
},
|
| 11665 |
+
"react": {
|
| 11666 |
+
"optional": true
|
| 11667 |
+
},
|
| 11668 |
+
"use-sync-external-store": {
|
| 11669 |
+
"optional": true
|
| 11670 |
+
}
|
| 11671 |
+
}
|
| 11672 |
+
},
|
| 11673 |
"node_modules/zwitch": {
|
| 11674 |
"version": "2.0.4",
|
| 11675 |
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
frontend/package.json
CHANGED
|
@@ -23,7 +23,8 @@
|
|
| 23 |
"remark-gfm": "^4.0.1",
|
| 24 |
"shadcn": "^4.3.1",
|
| 25 |
"tailwind-merge": "^3.5.0",
|
| 26 |
-
"tw-animate-css": "^1.4.0"
|
|
|
|
| 27 |
},
|
| 28 |
"devDependencies": {
|
| 29 |
"@tailwindcss/postcss": "^4",
|
|
|
|
| 23 |
"remark-gfm": "^4.0.1",
|
| 24 |
"shadcn": "^4.3.1",
|
| 25 |
"tailwind-merge": "^3.5.0",
|
| 26 |
+
"tw-animate-css": "^1.4.0",
|
| 27 |
+
"zustand": "^5.0.13"
|
| 28 |
},
|
| 29 |
"devDependencies": {
|
| 30 |
"@tailwindcss/postcss": "^4",
|
frontend/src/components/chat/ChatPanel.tsx
CHANGED
|
@@ -3,38 +3,28 @@
|
|
| 3 |
import { useState, useRef, useEffect } from "react";
|
| 4 |
import type { DocInfo } from "@/app/dashboard/page";
|
| 5 |
import { api, API_BASE } from "@/lib/api";
|
|
|
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { Textarea } from "@/components/ui/textarea";
|
| 8 |
import MessageBubble from "./MessageBubble";
|
| 9 |
import SourceCard from "./SourceCard";
|
| 10 |
import { Send, Loader2, Trash2, MessageSquare, Download } from "lucide-react";
|
| 11 |
|
| 12 |
-
export interface SourceChunk {
|
| 13 |
-
text: string;
|
| 14 |
-
filename: string;
|
| 15 |
-
page: number;
|
| 16 |
-
score: number;
|
| 17 |
-
confidence: number;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
export interface ChatMsg {
|
| 21 |
-
id: string;
|
| 22 |
-
role: "user" | "assistant";
|
| 23 |
-
content: string;
|
| 24 |
-
sources: SourceChunk[];
|
| 25 |
-
isStreaming?: boolean;
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
interface Props {
|
| 29 |
activeDoc: DocInfo | null;
|
| 30 |
onCitationClick: (page: number) => void;
|
| 31 |
}
|
| 32 |
|
| 33 |
export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
| 34 |
-
const
|
| 35 |
-
const
|
| 36 |
-
const
|
| 37 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const [showExportMenu, setShowExportMenu] = useState(false);
|
| 39 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 40 |
const bottomRef = useRef<HTMLDivElement>(null);
|
|
@@ -63,6 +53,12 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 63 |
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 64 |
}, [messages]);
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
// Load history on doc change
|
| 67 |
useEffect(() => {
|
| 68 |
if (!activeDoc) {
|
|
@@ -102,7 +98,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 102 |
return () => {
|
| 103 |
cancelled = true;
|
| 104 |
};
|
| 105 |
-
}, [activeDoc]);
|
| 106 |
|
| 107 |
const handleSend = async () => {
|
| 108 |
if (!input.trim() || streaming) return;
|
|
@@ -211,7 +207,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 211 |
}
|
| 212 |
};
|
| 213 |
|
| 214 |
-
const handleExport = (format: "md" | "txt") => {
|
| 215 |
if (!activeDoc) return;
|
| 216 |
setShowExportMenu(false);
|
| 217 |
const token = localStorage.getItem("token");
|
|
@@ -350,6 +346,14 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 350 |
<span className="text-base">📄</span>
|
| 351 |
Plain Text (.txt)
|
| 352 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
</div>
|
| 354 |
)}
|
| 355 |
</div>
|
|
@@ -369,4 +373,4 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 369 |
</div>
|
| 370 |
</div>
|
| 371 |
);
|
| 372 |
-
}
|
|
|
|
| 3 |
import { useState, useRef, useEffect } from "react";
|
| 4 |
import type { DocInfo } from "@/app/dashboard/page";
|
| 5 |
import { api, API_BASE } from "@/lib/api";
|
| 6 |
+
import { useChatStore, type ChatMsg, type SourceChunk } from "@/store/chat-store";
|
| 7 |
import { Button } from "@/components/ui/button";
|
| 8 |
import { Textarea } from "@/components/ui/textarea";
|
| 9 |
import MessageBubble from "./MessageBubble";
|
| 10 |
import SourceCard from "./SourceCard";
|
| 11 |
import { Send, Loader2, Trash2, MessageSquare, Download } from "lucide-react";
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
interface Props {
|
| 14 |
activeDoc: DocInfo | null;
|
| 15 |
onCitationClick: (page: number) => void;
|
| 16 |
}
|
| 17 |
|
| 18 |
export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
| 19 |
+
const messages = useChatStore((state) => state.messages);
|
| 20 |
+
const input = useChatStore((state) => state.input);
|
| 21 |
+
const streaming = useChatStore((state) => state.streaming);
|
| 22 |
+
const isTyping = useChatStore((state) => state.isTyping);
|
| 23 |
+
const setMessages = useChatStore((state) => state.setMessages);
|
| 24 |
+
const setInput = useChatStore((state) => state.setInput);
|
| 25 |
+
const setStreaming = useChatStore((state) => state.setStreaming);
|
| 26 |
+
const setIsTyping = useChatStore((state) => state.setIsTyping);
|
| 27 |
+
const resetChat = useChatStore((state) => state.resetChat);
|
| 28 |
const [showExportMenu, setShowExportMenu] = useState(false);
|
| 29 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 30 |
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 53 |
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 54 |
}, [messages]);
|
| 55 |
|
| 56 |
+
useEffect(() => {
|
| 57 |
+
return () => {
|
| 58 |
+
resetChat();
|
| 59 |
+
};
|
| 60 |
+
}, [resetChat]);
|
| 61 |
+
|
| 62 |
// Load history on doc change
|
| 63 |
useEffect(() => {
|
| 64 |
if (!activeDoc) {
|
|
|
|
| 98 |
return () => {
|
| 99 |
cancelled = true;
|
| 100 |
};
|
| 101 |
+
}, [activeDoc, resetChat, setMessages]);
|
| 102 |
|
| 103 |
const handleSend = async () => {
|
| 104 |
if (!input.trim() || streaming) return;
|
|
|
|
| 207 |
}
|
| 208 |
};
|
| 209 |
|
| 210 |
+
const handleExport = (format: "md" | "txt" | "pdf") => {
|
| 211 |
if (!activeDoc) return;
|
| 212 |
setShowExportMenu(false);
|
| 213 |
const token = localStorage.getItem("token");
|
|
|
|
| 346 |
<span className="text-base">📄</span>
|
| 347 |
Plain Text (.txt)
|
| 348 |
</button>
|
| 349 |
+
<button
|
| 350 |
+
id="export-pdf-btn"
|
| 351 |
+
onClick={() => handleExport("pdf")}
|
| 352 |
+
className="w-full flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
| 353 |
+
>
|
| 354 |
+
<span className="text-base">📕</span>
|
| 355 |
+
PDF (.pdf)
|
| 356 |
+
</button>
|
| 357 |
</div>
|
| 358 |
)}
|
| 359 |
</div>
|
|
|
|
| 373 |
</div>
|
| 374 |
</div>
|
| 375 |
);
|
| 376 |
+
}
|
frontend/src/components/chat/MessageBubble.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import { useState, useRef } from "react";
|
| 4 |
import ReactMarkdown from "react-markdown";
|
| 5 |
import remarkGfm from "remark-gfm";
|
| 6 |
-
import type { ChatMsg } from "
|
| 7 |
import { Brain, User, Copy, Check } from "lucide-react";
|
| 8 |
import { Button } from "@/components/ui/button";
|
| 9 |
|
|
|
|
| 3 |
import { useState, useRef } from "react";
|
| 4 |
import ReactMarkdown from "react-markdown";
|
| 5 |
import remarkGfm from "remark-gfm";
|
| 6 |
+
import type { ChatMsg } from "@/store/chat-store";
|
| 7 |
import { Brain, User, Copy, Check } from "lucide-react";
|
| 8 |
import { Button } from "@/components/ui/button";
|
| 9 |
|
frontend/src/components/chat/SourceCard.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState } from "react";
|
| 4 |
-
import type { SourceChunk } from "
|
| 5 |
import { Badge } from "@/components/ui/badge";
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { ChevronDown, ChevronUp, FileText, Eye } from "lucide-react";
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState } from "react";
|
| 4 |
+
import type { SourceChunk } from "@/store/chat-store";
|
| 5 |
import { Badge } from "@/components/ui/badge";
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
import { ChevronDown, ChevronUp, FileText, Eye } from "lucide-react";
|
frontend/src/lib/auth.tsx
CHANGED
|
@@ -1,73 +1,24 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import React, {
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
interface User {
|
| 7 |
-
id: string;
|
| 8 |
-
username: string;
|
| 9 |
-
email: string;
|
| 10 |
-
is_admin: boolean;
|
| 11 |
-
created_at: string;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
interface AuthContextType {
|
| 15 |
-
user: User | null;
|
| 16 |
-
token: string | null;
|
| 17 |
-
loading: boolean;
|
| 18 |
-
login: (email: string, password: string) => Promise<void>;
|
| 19 |
-
register: (username: string, email: string, password: string) => Promise<void>;
|
| 20 |
-
logout: () => void;
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
| 24 |
|
| 25 |
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 26 |
-
const
|
| 27 |
-
|
| 28 |
-
const
|
| 29 |
-
() => (typeof window !== "undefined" ? localStorage.getItem("token") : null)
|
| 30 |
-
);
|
| 31 |
-
// loading=true only when a token exists and needs server validation.
|
| 32 |
-
// If there's no token we're already done — no effect setState needed.
|
| 33 |
-
const [loading, setLoading] = useState<boolean>(
|
| 34 |
-
() => typeof window !== "undefined" && !!localStorage.getItem("token")
|
| 35 |
-
);
|
| 36 |
|
| 37 |
-
// ── Validate saved token on mount ─────────────────
|
| 38 |
-
// NOTE: no synchronous setState here — setLoading/setUser/setToken are
|
| 39 |
-
// only called inside async callbacks (.then / .catch / .finally).
|
| 40 |
useEffect(() => {
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
.get<User>("/api/v1/auth/me", { token })
|
| 44 |
-
.then(setUser)
|
| 45 |
-
.catch(() => {
|
| 46 |
-
localStorage.removeItem("token");
|
| 47 |
-
localStorage.removeItem("refresh_token");
|
| 48 |
-
setToken(null);
|
| 49 |
-
})
|
| 50 |
-
.finally(() => setLoading(false));
|
| 51 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 52 |
-
}, []); // intentionally runs once on mount only
|
| 53 |
|
| 54 |
-
// ── Listen for token refresh events from ApiClient ──
|
| 55 |
-
// When the API client auto-refreshes tokens, it dispatches custom events
|
| 56 |
-
// so this context stays in sync without prop drilling.
|
| 57 |
useEffect(() => {
|
| 58 |
const handleTokensRefreshed = (e: Event) => {
|
| 59 |
-
|
| 60 |
-
if (detail?.accessToken) {
|
| 61 |
-
setToken(detail.accessToken);
|
| 62 |
-
}
|
| 63 |
-
if (detail?.user) {
|
| 64 |
-
setUser(detail.user);
|
| 65 |
-
}
|
| 66 |
};
|
| 67 |
|
| 68 |
const handleLoggedOut = () => {
|
| 69 |
-
|
| 70 |
-
setUser(null);
|
| 71 |
};
|
| 72 |
|
| 73 |
window.addEventListener("auth:tokens-refreshed", handleTokensRefreshed);
|
|
@@ -77,46 +28,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
| 77 |
window.removeEventListener("auth:tokens-refreshed", handleTokensRefreshed);
|
| 78 |
window.removeEventListener("auth:logged-out", handleLoggedOut);
|
| 79 |
};
|
| 80 |
-
}, []);
|
| 81 |
-
|
| 82 |
-
const login = useCallback(async (email: string, password: string) => {
|
| 83 |
-
const data = await api.post<{ access_token: string; refresh_token: string; user: User }>(
|
| 84 |
-
"/api/v1/auth/login",
|
| 85 |
-
{ email, password }
|
| 86 |
-
);
|
| 87 |
-
localStorage.setItem("token", data.access_token);
|
| 88 |
-
localStorage.setItem("refresh_token", data.refresh_token);
|
| 89 |
-
setToken(data.access_token);
|
| 90 |
-
setUser(data.user);
|
| 91 |
-
}, []);
|
| 92 |
-
|
| 93 |
-
const register = useCallback(async (username: string, email: string, password: string) => {
|
| 94 |
-
const data = await api.post<{ access_token: string; refresh_token: string; user: User }>(
|
| 95 |
-
"/api/v1/auth/register",
|
| 96 |
-
{ username, email, password }
|
| 97 |
-
);
|
| 98 |
-
localStorage.setItem("token", data.access_token);
|
| 99 |
-
localStorage.setItem("refresh_token", data.refresh_token);
|
| 100 |
-
setToken(data.access_token);
|
| 101 |
-
setUser(data.user);
|
| 102 |
-
}, []);
|
| 103 |
-
|
| 104 |
-
const logout = useCallback(() => {
|
| 105 |
-
localStorage.removeItem("token");
|
| 106 |
-
localStorage.removeItem("refresh_token");
|
| 107 |
-
setToken(null);
|
| 108 |
-
setUser(null);
|
| 109 |
-
}, []);
|
| 110 |
|
| 111 |
-
return
|
| 112 |
-
<AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
|
| 113 |
-
{children}
|
| 114 |
-
</AuthContext.Provider>
|
| 115 |
-
);
|
| 116 |
}
|
| 117 |
|
| 118 |
export function useAuth() {
|
| 119 |
-
|
| 120 |
-
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
| 121 |
-
return ctx;
|
| 122 |
}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import React, { useEffect } from "react";
|
| 4 |
+
import { useAuthStore } from "@/store/auth-store";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 7 |
+
const initializeAuth = useAuthStore((state) => state.initializeAuth);
|
| 8 |
+
const syncTokensRefreshed = useAuthStore((state) => state.syncTokensRefreshed);
|
| 9 |
+
const syncLoggedOut = useAuthStore((state) => state.syncLoggedOut);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
useEffect(() => {
|
| 12 |
+
void initializeAuth();
|
| 13 |
+
}, [initializeAuth]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
useEffect(() => {
|
| 16 |
const handleTokensRefreshed = (e: Event) => {
|
| 17 |
+
syncTokensRefreshed((e as CustomEvent).detail);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
};
|
| 19 |
|
| 20 |
const handleLoggedOut = () => {
|
| 21 |
+
syncLoggedOut();
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
window.addEventListener("auth:tokens-refreshed", handleTokensRefreshed);
|
|
|
|
| 28 |
window.removeEventListener("auth:tokens-refreshed", handleTokensRefreshed);
|
| 29 |
window.removeEventListener("auth:logged-out", handleLoggedOut);
|
| 30 |
};
|
| 31 |
+
}, [syncLoggedOut, syncTokensRefreshed]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
return <>{children}</>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
export function useAuth() {
|
| 37 |
+
return useAuthStore();
|
|
|
|
|
|
|
| 38 |
}
|
frontend/src/store/auth-store.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { create } from "zustand";
|
| 4 |
+
import { api } from "@/lib/api";
|
| 5 |
+
|
| 6 |
+
export interface AuthUser {
|
| 7 |
+
id: string;
|
| 8 |
+
username: string;
|
| 9 |
+
email: string;
|
| 10 |
+
is_admin: boolean;
|
| 11 |
+
created_at: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface AuthStore {
|
| 15 |
+
user: AuthUser | null;
|
| 16 |
+
token: string | null;
|
| 17 |
+
loading: boolean;
|
| 18 |
+
initialized: boolean;
|
| 19 |
+
login: (email: string, password: string) => Promise<void>;
|
| 20 |
+
register: (username: string, email: string, password: string) => Promise<void>;
|
| 21 |
+
logout: () => void;
|
| 22 |
+
initializeAuth: () => Promise<void>;
|
| 23 |
+
syncTokensRefreshed: (detail?: { accessToken?: string; user?: AuthUser | null }) => void;
|
| 24 |
+
syncLoggedOut: () => void;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const getStoredToken = () =>
|
| 28 |
+
typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
| 29 |
+
|
| 30 |
+
const clearStoredTokens = () => {
|
| 31 |
+
if (typeof window === "undefined") return;
|
| 32 |
+
localStorage.removeItem("token");
|
| 33 |
+
localStorage.removeItem("refresh_token");
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
export const useAuthStore = create<AuthStore>((set, get) => ({
|
| 37 |
+
user: null,
|
| 38 |
+
token: getStoredToken(),
|
| 39 |
+
loading: !!getStoredToken(),
|
| 40 |
+
initialized: false,
|
| 41 |
+
|
| 42 |
+
async login(email, password) {
|
| 43 |
+
const data = await api.post<{ access_token: string; refresh_token: string; user: AuthUser }>(
|
| 44 |
+
"/api/v1/auth/login",
|
| 45 |
+
{ email, password }
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
localStorage.setItem("token", data.access_token);
|
| 49 |
+
localStorage.setItem("refresh_token", data.refresh_token);
|
| 50 |
+
set({
|
| 51 |
+
token: data.access_token,
|
| 52 |
+
user: data.user,
|
| 53 |
+
loading: false,
|
| 54 |
+
initialized: true,
|
| 55 |
+
});
|
| 56 |
+
},
|
| 57 |
+
|
| 58 |
+
async register(username, email, password) {
|
| 59 |
+
const data = await api.post<{ access_token: string; refresh_token: string; user: AuthUser }>(
|
| 60 |
+
"/api/v1/auth/register",
|
| 61 |
+
{ username, email, password }
|
| 62 |
+
);
|
| 63 |
+
|
| 64 |
+
localStorage.setItem("token", data.access_token);
|
| 65 |
+
localStorage.setItem("refresh_token", data.refresh_token);
|
| 66 |
+
set({
|
| 67 |
+
token: data.access_token,
|
| 68 |
+
user: data.user,
|
| 69 |
+
loading: false,
|
| 70 |
+
initialized: true,
|
| 71 |
+
});
|
| 72 |
+
},
|
| 73 |
+
|
| 74 |
+
logout() {
|
| 75 |
+
clearStoredTokens();
|
| 76 |
+
set({
|
| 77 |
+
token: null,
|
| 78 |
+
user: null,
|
| 79 |
+
loading: false,
|
| 80 |
+
initialized: true,
|
| 81 |
+
});
|
| 82 |
+
},
|
| 83 |
+
|
| 84 |
+
async initializeAuth() {
|
| 85 |
+
const { initialized, token } = get();
|
| 86 |
+
if (initialized) return;
|
| 87 |
+
|
| 88 |
+
const storedToken = token ?? getStoredToken();
|
| 89 |
+
if (!storedToken) {
|
| 90 |
+
set({ token: null, user: null, loading: false, initialized: true });
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
set({ token: storedToken, loading: true });
|
| 95 |
+
|
| 96 |
+
try {
|
| 97 |
+
const user = await api.get<AuthUser>("/api/v1/auth/me", { token: storedToken });
|
| 98 |
+
set({ user, token: storedToken, loading: false, initialized: true });
|
| 99 |
+
} catch {
|
| 100 |
+
clearStoredTokens();
|
| 101 |
+
set({ user: null, token: null, loading: false, initialized: true });
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
|
| 105 |
+
syncTokensRefreshed(detail) {
|
| 106 |
+
if (!detail) return;
|
| 107 |
+
|
| 108 |
+
set((state) => ({
|
| 109 |
+
token: detail.accessToken ?? state.token,
|
| 110 |
+
user: detail.user ?? state.user,
|
| 111 |
+
loading: false,
|
| 112 |
+
initialized: true,
|
| 113 |
+
}));
|
| 114 |
+
},
|
| 115 |
+
|
| 116 |
+
syncLoggedOut() {
|
| 117 |
+
set({
|
| 118 |
+
token: null,
|
| 119 |
+
user: null,
|
| 120 |
+
loading: false,
|
| 121 |
+
initialized: true,
|
| 122 |
+
});
|
| 123 |
+
},
|
| 124 |
+
}));
|
frontend/src/store/chat-store.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { create } from "zustand";
|
| 4 |
+
|
| 5 |
+
export interface SourceChunk {
|
| 6 |
+
text: string;
|
| 7 |
+
filename: string;
|
| 8 |
+
page: number;
|
| 9 |
+
score: number;
|
| 10 |
+
confidence: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export interface ChatMsg {
|
| 14 |
+
id: string;
|
| 15 |
+
role: "user" | "assistant";
|
| 16 |
+
content: string;
|
| 17 |
+
sources: SourceChunk[];
|
| 18 |
+
isStreaming?: boolean;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
type Setter<T> = T | ((prev: T) => T);
|
| 22 |
+
|
| 23 |
+
interface ChatStore {
|
| 24 |
+
messages: ChatMsg[];
|
| 25 |
+
input: string;
|
| 26 |
+
streaming: boolean;
|
| 27 |
+
isTyping: boolean;
|
| 28 |
+
setMessages: (value: Setter<ChatMsg[]>) => void;
|
| 29 |
+
setInput: (value: Setter<string>) => void;
|
| 30 |
+
setStreaming: (value: Setter<boolean>) => void;
|
| 31 |
+
setIsTyping: (value: Setter<boolean>) => void;
|
| 32 |
+
resetChat: () => void;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const resolveValue = <T,>(value: Setter<T>, current: T): T =>
|
| 36 |
+
typeof value === "function" ? (value as (prev: T) => T)(current) : value;
|
| 37 |
+
|
| 38 |
+
export const useChatStore = create<ChatStore>((set) => ({
|
| 39 |
+
messages: [],
|
| 40 |
+
input: "",
|
| 41 |
+
streaming: false,
|
| 42 |
+
isTyping: false,
|
| 43 |
+
|
| 44 |
+
setMessages(value) {
|
| 45 |
+
set((state) => ({ messages: resolveValue(value, state.messages) }));
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
setInput(value) {
|
| 49 |
+
set((state) => ({ input: resolveValue(value, state.input) }));
|
| 50 |
+
},
|
| 51 |
+
|
| 52 |
+
setStreaming(value) {
|
| 53 |
+
set((state) => ({ streaming: resolveValue(value, state.streaming) }));
|
| 54 |
+
},
|
| 55 |
+
|
| 56 |
+
setIsTyping(value) {
|
| 57 |
+
set((state) => ({ isTyping: resolveValue(value, state.isTyping) }));
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
resetChat() {
|
| 61 |
+
set({
|
| 62 |
+
messages: [],
|
| 63 |
+
input: "",
|
| 64 |
+
streaming: false,
|
| 65 |
+
isTyping: false,
|
| 66 |
+
});
|
| 67 |
+
},
|
| 68 |
+
}));
|