Spaces:
Running
Running
Merge pull request #237 from Exodus2004/feat/chat-sessions
Browse files- backend/app/models.py +20 -13
- backend/app/routes/chat.py +183 -276
- backend/app/schemas.py +16 -0
- frontend/src/app/dashboard/page.tsx +4 -1
- frontend/src/components/chat/ChatPanel.tsx +10 -2
- frontend/src/components/chat/ChatSessionSidebar.tsx +184 -0
- frontend/src/store/chat-store.ts +104 -1
backend/app/models.py
CHANGED
|
@@ -8,11 +8,9 @@ import hashlib
|
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
|
| 10 |
from cryptography.fernet import Fernet
|
| 11 |
-
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text, Boolean
|
| 12 |
from sqlalchemy.types import TypeDecorator, CHAR
|
| 13 |
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
| 14 |
-
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text, Boolean, Enum as SQLAlchemyEnum
|
| 15 |
-
from sqlalchemy.types import TypeDecorator
|
| 16 |
from sqlalchemy.orm import relationship
|
| 17 |
|
| 18 |
from app.database import Base
|
|
@@ -85,11 +83,6 @@ class EncryptedString(TypeDecorator):
|
|
| 85 |
return value
|
| 86 |
|
| 87 |
|
| 88 |
-
def generate_uuid():
|
| 89 |
-
"""Generates a standard unique string identifier for database records."""
|
| 90 |
-
return str(uuid.uuid4())
|
| 91 |
-
|
| 92 |
-
|
| 93 |
class UserRole(str, enum.Enum):
|
| 94 |
"""
|
| 95 |
Defines the available user roles for Role-Based Access Control (RBAC).
|
|
@@ -129,6 +122,7 @@ class User(Base):
|
|
| 129 |
documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
|
| 130 |
messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
|
| 131 |
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
class ApiKey(Base):
|
|
@@ -148,6 +142,22 @@ class ApiKey(Base):
|
|
| 148 |
user = relationship("User", back_populates="api_keys")
|
| 149 |
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
class Document(Base):
|
| 152 |
"""
|
| 153 |
Metadata and processing status for files uploaded by users.
|
|
@@ -159,11 +169,6 @@ class Document(Base):
|
|
| 159 |
filename = Column(String(255), nullable=False) # Stored filename (UUID-based)
|
| 160 |
original_name = Column(String(255), nullable=False) # User's original filename
|
| 161 |
file_size = Column(Integer, default=0) # Size in bytes
|
| 162 |
-
id = Column(String, primary_key=True, default=generate_uuid)
|
| 163 |
-
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
|
| 164 |
-
filename = Column(String(255), nullable=False) # Internal UUID-based filename
|
| 165 |
-
original_name = Column(String(255), nullable=False) # Original name for user display
|
| 166 |
-
file_size = Column(Integer, default=0) # Size in bytes
|
| 167 |
page_count = Column(Integer, default=0)
|
| 168 |
chunk_count = Column(Integer, default=0)
|
| 169 |
status = Column(String(20), default="pending") # pending | processing | ready | failed
|
|
@@ -185,6 +190,7 @@ class ChatMessage(Base):
|
|
| 185 |
id = Column(GUID, primary_key=True, default=uuid.uuid4)
|
| 186 |
user_id = Column(GUID, ForeignKey("users.id"), nullable=False, index=True)
|
| 187 |
document_id = Column(GUID, ForeignKey("documents.id"), nullable=True, index=True)
|
|
|
|
| 188 |
role = Column(String(20), nullable=False) # "user" | "assistant"
|
| 189 |
content = Column(Text, nullable=False)
|
| 190 |
sources_json = Column(Text, nullable=True) # JSON representation of retrieved sources
|
|
@@ -193,6 +199,7 @@ class ChatMessage(Base):
|
|
| 193 |
# Relationships
|
| 194 |
user = relationship("User", back_populates="messages")
|
| 195 |
document = relationship("Document", back_populates="messages")
|
|
|
|
| 196 |
shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan")
|
| 197 |
|
| 198 |
|
|
|
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
|
| 10 |
from cryptography.fernet import Fernet
|
| 11 |
+
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Text, Boolean, Enum as SQLAlchemyEnum
|
| 12 |
from sqlalchemy.types import TypeDecorator, CHAR
|
| 13 |
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
|
|
|
|
|
| 14 |
from sqlalchemy.orm import relationship
|
| 15 |
|
| 16 |
from app.database import Base
|
|
|
|
| 83 |
return value
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
class UserRole(str, enum.Enum):
|
| 87 |
"""
|
| 88 |
Defines the available user roles for Role-Based Access Control (RBAC).
|
|
|
|
| 122 |
documents = relationship("Document", back_populates="owner", cascade="all, delete-orphan")
|
| 123 |
messages = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan")
|
| 124 |
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
|
| 125 |
+
chat_sessions = relationship("ChatSession", back_populates="user", cascade="all, delete-orphan")
|
| 126 |
|
| 127 |
|
| 128 |
class ApiKey(Base):
|
|
|
|
| 142 |
user = relationship("User", back_populates="api_keys")
|
| 143 |
|
| 144 |
|
| 145 |
+
class ChatSession(Base):
|
| 146 |
+
"""
|
| 147 |
+
Groups chat messages into logical sessions/threads.
|
| 148 |
+
"""
|
| 149 |
+
__tablename__ = "chat_sessions"
|
| 150 |
+
|
| 151 |
+
id = Column(GUID, primary_key=True, default=uuid.uuid4)
|
| 152 |
+
user_id = Column(GUID, ForeignKey("users.id"), nullable=False, index=True)
|
| 153 |
+
title = Column(String(255), nullable=False)
|
| 154 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 155 |
+
|
| 156 |
+
# Relationships
|
| 157 |
+
user = relationship("User", back_populates="chat_sessions")
|
| 158 |
+
messages = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
class Document(Base):
|
| 162 |
"""
|
| 163 |
Metadata and processing status for files uploaded by users.
|
|
|
|
| 169 |
filename = Column(String(255), nullable=False) # Stored filename (UUID-based)
|
| 170 |
original_name = Column(String(255), nullable=False) # User's original filename
|
| 171 |
file_size = Column(Integer, default=0) # Size in bytes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
page_count = Column(Integer, default=0)
|
| 173 |
chunk_count = Column(Integer, default=0)
|
| 174 |
status = Column(String(20), default="pending") # pending | processing | ready | failed
|
|
|
|
| 190 |
id = Column(GUID, primary_key=True, default=uuid.uuid4)
|
| 191 |
user_id = Column(GUID, ForeignKey("users.id"), nullable=False, index=True)
|
| 192 |
document_id = Column(GUID, ForeignKey("documents.id"), nullable=True, index=True)
|
| 193 |
+
session_id = Column(GUID, ForeignKey("chat_sessions.id"), nullable=True, index=True)
|
| 194 |
role = Column(String(20), nullable=False) # "user" | "assistant"
|
| 195 |
content = Column(Text, nullable=False)
|
| 196 |
sources_json = Column(Text, nullable=True) # JSON representation of retrieved sources
|
|
|
|
| 199 |
# Relationships
|
| 200 |
user = relationship("User", back_populates="messages")
|
| 201 |
document = relationship("Document", back_populates="messages")
|
| 202 |
+
session = relationship("ChatSession", back_populates="messages")
|
| 203 |
shared_message = relationship("SharedMessage", back_populates="message", uselist=False, cascade="all, delete-orphan")
|
| 204 |
|
| 205 |
|
backend/app/routes/chat.py
CHANGED
|
@@ -7,20 +7,16 @@ import time
|
|
| 7 |
from datetime import datetime
|
| 8 |
from io import BytesIO
|
| 9 |
import logging
|
| 10 |
-
from typing import Optional
|
| 11 |
|
| 12 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 13 |
from fastapi.responses import Response, StreamingResponse
|
| 14 |
-
from reportlab.lib.pagesizes import letter
|
| 15 |
-
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 16 |
-
from reportlab.lib.units import inch
|
| 17 |
-
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
| 18 |
from sqlalchemy.orm import Session
|
| 19 |
|
| 20 |
from app.auth import get_current_user
|
| 21 |
from app.database import get_db
|
| 22 |
from app.metrics import record_query_response_time
|
| 23 |
-
from app.models import User, ChatMessage, Document, SharedMessage
|
| 24 |
from app.rate_limit import limiter
|
| 25 |
from app.schemas import (
|
| 26 |
ChatRequest,
|
|
@@ -30,6 +26,8 @@ from app.schemas import (
|
|
| 30 |
ShareAnswerResponse,
|
| 31 |
ShareLinkResponse,
|
| 32 |
SourceChunk,
|
|
|
|
|
|
|
| 33 |
)
|
| 34 |
|
| 35 |
logger = logging.getLogger(__name__)
|
|
@@ -77,11 +75,139 @@ def create_share_link(
|
|
| 77 |
db.commit()
|
| 78 |
|
| 79 |
return ShareLinkResponse(
|
| 80 |
-
message_id=message.id,
|
| 81 |
share_url=f"/share?message_id={message.id}",
|
| 82 |
)
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def generate_answer(question: str, user_id: str, document_id: Optional[str] = None, hf_token: Optional[str] = None):
|
| 86 |
from app.rag.agent import generate_answer as _generate_answer
|
| 87 |
|
|
@@ -102,33 +228,7 @@ def ask_question(
|
|
| 102 |
user: User = Depends(get_current_user),
|
| 103 |
db: Session = Depends(get_db),
|
| 104 |
):
|
| 105 |
-
"""Ask a question with RAG retrieval (non-streaming).
|
| 106 |
-
|
| 107 |
-
Processes a user's question by retrieving relevant document chunks,
|
| 108 |
-
generating an answer using an LLM, and saving the conversation to chat
|
| 109 |
-
history. If a `document_id` is provided, the retrieval is scoped to that
|
| 110 |
-
specific document; otherwise, it searches across all documents owned by
|
| 111 |
-
the user.
|
| 112 |
-
|
| 113 |
-
Args:
|
| 114 |
-
payload: ChatRequest containing the `question` text and optionally a
|
| 115 |
-
`document_id` to limit the retrieval scope.
|
| 116 |
-
user: The currently authenticated user, obtained from the dependency.
|
| 117 |
-
db: SQLAlchemy database session, obtained from the dependency.
|
| 118 |
-
|
| 119 |
-
Returns:
|
| 120 |
-
ChatResponse: An object containing:
|
| 121 |
-
- answer: The generated answer text.
|
| 122 |
-
- sources: A list of `SourceChunk` objects with metadata about
|
| 123 |
-
the retrieved chunks (e.g., filename, page number, text snippet).
|
| 124 |
-
- document_id: The document ID that was used (if any).
|
| 125 |
-
|
| 126 |
-
Raises:
|
| 127 |
-
HTTPException: 404 if the specified `document_id` does not exist or
|
| 128 |
-
does not belong to the authenticated user.
|
| 129 |
-
HTTPException: 400 if the document exists but its status is not
|
| 130 |
-
"ready" (e.g., still processing or failed).
|
| 131 |
-
"""
|
| 132 |
started_at = time.perf_counter()
|
| 133 |
try:
|
| 134 |
# Validate document exists if specified
|
|
@@ -147,6 +247,17 @@ def ask_question(
|
|
| 147 |
detail=f"Document is still {doc.status}. Please wait for processing to complete.",
|
| 148 |
)
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
result = generate_answer(
|
| 151 |
question=payload.question,
|
| 152 |
user_id=user.id,
|
|
@@ -155,8 +266,8 @@ def ask_question(
|
|
| 155 |
)
|
| 156 |
|
| 157 |
# Save to chat history
|
| 158 |
-
_save_message(db, user.id, payload.document_id, "user", payload.question)
|
| 159 |
-
_save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
|
| 160 |
|
| 161 |
return ChatResponse(
|
| 162 |
answer=result["answer"],
|
|
@@ -175,41 +286,7 @@ def ask_question_stream(
|
|
| 175 |
user: User = Depends(get_current_user),
|
| 176 |
db: Session = Depends(get_db),
|
| 177 |
):
|
| 178 |
-
"""Ask a question with Server-Sent Events (SSE) streaming response.
|
| 179 |
-
|
| 180 |
-
Processes a user's question using RAG and streams the answer token by
|
| 181 |
-
token over SSE. The user's question is saved to chat history immediately.
|
| 182 |
-
The assistant's answer is accumulated on the server and saved to history
|
| 183 |
-
only after the stream completes. If a `document_id` is provided, retrieval
|
| 184 |
-
is scoped to that document.
|
| 185 |
-
|
| 186 |
-
Args:
|
| 187 |
-
payload: ChatRequest containing the `question` text and optionally a
|
| 188 |
-
`document_id` to limit the retrieval scope.
|
| 189 |
-
user: The currently authenticated user, obtained from the dependency.
|
| 190 |
-
db: SQLAlchemy database session, obtained from the dependency.
|
| 191 |
-
|
| 192 |
-
Returns:
|
| 193 |
-
StreamingResponse: A FastAPI `StreamingResponse` with:
|
| 194 |
-
- media_type: "text/event-stream"
|
| 195 |
-
- Headers: Cache-Control, Connection, and X-Accel-Buffering set
|
| 196 |
-
for proper SSE behavior.
|
| 197 |
-
- Body: A generator yielding SSE messages with `token` (partial
|
| 198 |
-
answer) and `sources` (final source metadata) events.
|
| 199 |
-
|
| 200 |
-
Raises:
|
| 201 |
-
HTTPException: 404 if the specified `document_id` does not exist or
|
| 202 |
-
does not belong to the authenticated user.
|
| 203 |
-
HTTPException: 400 if the document exists but its status is not
|
| 204 |
-
"ready" (e.g., still processing or failed).
|
| 205 |
-
|
| 206 |
-
Note:
|
| 207 |
-
The streaming response uses a generator `event_stream` that yields
|
| 208 |
-
raw SSE chunks. The assistant's full answer is reconstructed from
|
| 209 |
-
the stream to save the complete conversation history. A separate
|
| 210 |
-
database session is created inside the generator to avoid using the
|
| 211 |
-
closed request session.
|
| 212 |
-
"""
|
| 213 |
# Validate document
|
| 214 |
if payload.document_id:
|
| 215 |
doc = db.query(Document).filter(
|
|
@@ -228,8 +305,19 @@ def ask_question_stream(
|
|
| 228 |
|
| 229 |
started_at = time.perf_counter()
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
# Save user message immediately
|
| 232 |
-
_save_message(db, user.id, payload.document_id, "user", payload.question)
|
| 233 |
|
| 234 |
# Stream response
|
| 235 |
def event_stream():
|
|
@@ -260,7 +348,7 @@ def ask_question_stream(
|
|
| 260 |
from app.database import SessionLocal
|
| 261 |
save_db = SessionLocal()
|
| 262 |
try:
|
| 263 |
-
_save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
|
| 264 |
finally:
|
| 265 |
save_db.close()
|
| 266 |
finally:
|
|
@@ -283,25 +371,7 @@ def get_chat_history(
|
|
| 283 |
user: User = Depends(get_current_user),
|
| 284 |
db: Session = Depends(get_db),
|
| 285 |
):
|
| 286 |
-
"""Retrieve the complete chat history for a specific document.
|
| 287 |
-
|
| 288 |
-
Fetches all messages (both user and assistant) associated with the given
|
| 289 |
-
document and the authenticated user, ordered chronologically from oldest
|
| 290 |
-
to newest. Assistant messages that contain source metadata will have the
|
| 291 |
-
`sources` field populated.
|
| 292 |
-
|
| 293 |
-
Args:
|
| 294 |
-
document_id: The unique identifier of the document whose chat history is requested.
|
| 295 |
-
user: The currently authenticated user, obtained from the dependency.
|
| 296 |
-
db: SQLAlchemy database session, obtained from the dependency.
|
| 297 |
-
|
| 298 |
-
Returns:
|
| 299 |
-
ChatHistoryResponse: An object containing:
|
| 300 |
-
- messages: A list of `ChatMessageResponse` objects, each with
|
| 301 |
-
`id`, `role` ("user" or "assistant"), `content`, `sources`
|
| 302 |
-
(list of `SourceChunk` for assistant messages), and `created_at`.
|
| 303 |
-
- document_id: The document ID that was queried.
|
| 304 |
-
"""
|
| 305 |
messages = (
|
| 306 |
db.query(ChatMessage)
|
| 307 |
.filter(
|
|
@@ -322,7 +392,7 @@ def get_chat_history(
|
|
| 322 |
pass
|
| 323 |
|
| 324 |
formatted.append(ChatMessageResponse(
|
| 325 |
-
id=msg.id,
|
| 326 |
role=msg.role,
|
| 327 |
content=msg.content,
|
| 328 |
sources=sources,
|
|
@@ -339,33 +409,7 @@ def export_chat_history(
|
|
| 339 |
token: Optional[str] = None,
|
| 340 |
db: Session = Depends(get_db),
|
| 341 |
):
|
| 342 |
-
"""Export the chat history for a document as a downloadable file.
|
| 343 |
-
|
| 344 |
-
Supports Markdown (.md), plain text (.txt), or PDF (.pdf) export. The function accepts
|
| 345 |
-
authentication via either the standard `Authorization: Bearer <token>`
|
| 346 |
-
header (handled by the dependency chain) or a `token` query parameter to
|
| 347 |
-
facilitate browser-initiated downloads that cannot set custom headers.
|
| 348 |
-
|
| 349 |
-
Args:
|
| 350 |
-
document_id: The unique identifier of the document whose chat history is to be exported.
|
| 351 |
-
format: Output format, either "md" (Markdown), "txt" (plain text), or "pdf". Defaults to "md".
|
| 352 |
-
token: Optional JWT token passed as a query parameter. Used for browser
|
| 353 |
-
downloads when the `Authorization` header is not available.
|
| 354 |
-
db: SQLAlchemy database session, obtained from the dependency.
|
| 355 |
-
|
| 356 |
-
Returns:
|
| 357 |
-
Response: A FastAPI `Response` object with:
|
| 358 |
-
- `content`: Formatted chat history as a string or PDF bytes.
|
| 359 |
-
- `media_type`: `text/markdown`, `text/plain`, or `application/pdf`.
|
| 360 |
-
- `headers`: `Content-Disposition` attachment header with a generated filename.
|
| 361 |
-
|
| 362 |
-
Raises:
|
| 363 |
-
HTTPException: 401 if neither the token query parameter nor a valid
|
| 364 |
-
bearer token provides an authenticated user.
|
| 365 |
-
HTTPException: 400 if the `format` parameter is not "md", "txt", or "pdf".
|
| 366 |
-
HTTPException: 404 if the document does not exist or does not belong
|
| 367 |
-
to the user, or if no chat messages are found for the document.
|
| 368 |
-
"""
|
| 369 |
from app.auth import decode_token as _decode
|
| 370 |
|
| 371 |
# Resolve user from query-param token (browser download links can't set headers)
|
|
@@ -412,6 +456,7 @@ def export_chat_history(
|
|
| 412 |
media_type = "text/plain"
|
| 413 |
extension = "txt"
|
| 414 |
else:
|
|
|
|
| 415 |
content = _format_pdf(doc, messages)
|
| 416 |
media_type = "application/pdf"
|
| 417 |
extension = "pdf"
|
|
@@ -434,20 +479,7 @@ def clear_chat_history(
|
|
| 434 |
user: User = Depends(get_current_user),
|
| 435 |
db: Session = Depends(get_db),
|
| 436 |
):
|
| 437 |
-
"""Delete all chat messages associated with a specific document.
|
| 438 |
-
|
| 439 |
-
Removes every chat message (both user and assistant) linked to the given
|
| 440 |
-
`document_id` and the authenticated user. The deletion is permanent and
|
| 441 |
-
cannot be undone.
|
| 442 |
-
|
| 443 |
-
Args:
|
| 444 |
-
document_id: The unique identifier of the document whose chat history should be cleared.
|
| 445 |
-
user: The currently authenticated user, obtained from the dependency.
|
| 446 |
-
db: SQLAlchemy database session, obtained from the dependency.
|
| 447 |
-
|
| 448 |
-
Returns:
|
| 449 |
-
dict: A simple JSON object with a `message` field confirming the deletion.
|
| 450 |
-
"""
|
| 451 |
db.query(ChatMessage).filter(
|
| 452 |
ChatMessage.user_id == user.id,
|
| 453 |
ChatMessage.document_id == document_id,
|
|
@@ -464,35 +496,22 @@ def _save_message(
|
|
| 464 |
role: str,
|
| 465 |
content: str,
|
| 466 |
sources: list = None,
|
|
|
|
| 467 |
):
|
| 468 |
-
"""Save a chat message to the database.
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
Can be `None` for global chat contexts.
|
| 479 |
-
db: SQLAlchemy database session (active, typically from a dependency).
|
| 480 |
-
role: The message sender role, e.g., "user" or "assistant".
|
| 481 |
-
content: The full text content of the message.
|
| 482 |
-
sources: Optional list of source dictionaries (usually from RAG
|
| 483 |
-
retrieval) to be stored as JSON. Defaults to `None`.
|
| 484 |
-
|
| 485 |
-
Returns:
|
| 486 |
-
None
|
| 487 |
-
|
| 488 |
-
Note:
|
| 489 |
-
The function commits the transaction. It does not close the session,
|
| 490 |
-
leaving that responsibility to the caller. If `sources` is provided,
|
| 491 |
-
it is serialized using `json.dumps()`.
|
| 492 |
-
"""
|
| 493 |
msg = ChatMessage(
|
| 494 |
user_id=user_id,
|
| 495 |
document_id=document_id,
|
|
|
|
| 496 |
role=role,
|
| 497 |
content=content,
|
| 498 |
sources_json=json.dumps(sources) if sources else None,
|
|
@@ -511,7 +530,7 @@ def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse:
|
|
| 511 |
sources = []
|
| 512 |
|
| 513 |
return ShareAnswerResponse(
|
| 514 |
-
id=message.id,
|
| 515 |
content=message.content,
|
| 516 |
created_at=message.created_at,
|
| 517 |
sources=sources,
|
|
@@ -519,28 +538,12 @@ def _share_answer_response(message: ChatMessage) -> ShareAnswerResponse:
|
|
| 519 |
|
| 520 |
|
| 521 |
def _format_markdown(doc, messages) -> str:
|
| 522 |
-
"""Format chat history as a Markdown document.
|
| 523 |
-
|
| 524 |
-
Generates a Markdown string containing the document metadata and the
|
| 525 |
-
full conversation. User messages are labeled "You", assistant messages
|
| 526 |
-
are labeled "Assistant". For assistant responses, if source information
|
| 527 |
-
is available, it is rendered as a numbered list with filename, page,
|
| 528 |
-
confidence, and a text preview.
|
| 529 |
-
|
| 530 |
-
Args:
|
| 531 |
-
doc: The Document object (must have `original_name` attribute).
|
| 532 |
-
messages: List of ChatMessage objects, each with attributes:
|
| 533 |
-
`role` (str), `content` (str), `created_at` (datetime, optional),
|
| 534 |
-
and `sources_json` (str, JSON-encoded list of source dicts).
|
| 535 |
-
|
| 536 |
-
Returns:
|
| 537 |
-
str: A Markdown string ready for writing to a `.md` file.
|
| 538 |
-
"""
|
| 539 |
lines = [
|
| 540 |
f"# Chat History — {doc.original_name}",
|
| 541 |
"",
|
| 542 |
f"**Document:** {doc.original_name} ",
|
| 543 |
-
f"**Exported at:** {
|
| 544 |
f"**Total messages:** {len(messages)}",
|
| 545 |
"",
|
| 546 |
"---",
|
|
@@ -557,7 +560,6 @@ def _format_markdown(doc, messages) -> str:
|
|
| 557 |
lines.append(msg.content)
|
| 558 |
lines.append("")
|
| 559 |
|
| 560 |
-
# Include source citations for assistant messages
|
| 561 |
if msg.role == "assistant" and msg.sources_json:
|
| 562 |
try:
|
| 563 |
sources = json.loads(msg.sources_json)
|
|
@@ -583,26 +585,10 @@ def _format_markdown(doc, messages) -> str:
|
|
| 583 |
|
| 584 |
|
| 585 |
def _format_plaintext(doc, messages) -> str:
|
| 586 |
-
"""Format chat history as a plain text document.
|
| 587 |
-
|
| 588 |
-
Generates a plain text string containing the document metadata and the
|
| 589 |
-
full conversation. User messages are labeled "You", assistant messages
|
| 590 |
-
are labeled "Assistant". For assistant responses, if source information
|
| 591 |
-
is available, it is rendered as a numbered list with filename, page,
|
| 592 |
-
and confidence (text preview is omitted in plain text format).
|
| 593 |
-
|
| 594 |
-
Args:
|
| 595 |
-
doc: The Document object (must have `original_name` attribute).
|
| 596 |
-
messages: List of ChatMessage objects, each with attributes:
|
| 597 |
-
`role` (str), `content` (str), `created_at` (datetime, optional),
|
| 598 |
-
and `sources_json` (str, JSON‑encoded list of source dicts).
|
| 599 |
-
|
| 600 |
-
Returns:
|
| 601 |
-
str: A plain text string ready for writing to a `.txt` file.
|
| 602 |
-
"""
|
| 603 |
lines = [
|
| 604 |
f"Chat History — {doc.original_name}",
|
| 605 |
-
f"Exported at: {
|
| 606 |
f"Total messages: {len(messages)}",
|
| 607 |
"=" * 60,
|
| 608 |
"",
|
|
@@ -615,7 +601,6 @@ def _format_plaintext(doc, messages) -> str:
|
|
| 615 |
lines.append(f"[{role_label}] ({timestamp})")
|
| 616 |
lines.append(msg.content)
|
| 617 |
|
| 618 |
-
# Include source citations for assistant messages
|
| 619 |
if msg.role == "assistant" and msg.sources_json:
|
| 620 |
try:
|
| 621 |
sources = json.loads(msg.sources_json)
|
|
@@ -633,81 +618,3 @@ def _format_plaintext(doc, messages) -> str:
|
|
| 633 |
lines.append("")
|
| 634 |
|
| 635 |
return "\n".join(lines)
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
def _format_pdf(doc, messages) -> bytes:
|
| 639 |
-
"""Format chat history as a PDF document."""
|
| 640 |
-
buffer = BytesIO()
|
| 641 |
-
pdf = SimpleDocTemplate(
|
| 642 |
-
buffer,
|
| 643 |
-
pagesize=letter,
|
| 644 |
-
leftMargin=0.75 * inch,
|
| 645 |
-
rightMargin=0.75 * inch,
|
| 646 |
-
topMargin=0.75 * inch,
|
| 647 |
-
bottomMargin=0.75 * inch,
|
| 648 |
-
)
|
| 649 |
-
|
| 650 |
-
styles = getSampleStyleSheet()
|
| 651 |
-
metadata_style = styles["Normal"]
|
| 652 |
-
metadata_style.spaceAfter = 6
|
| 653 |
-
content_style = ParagraphStyle(
|
| 654 |
-
"ChatContent",
|
| 655 |
-
parent=styles["BodyText"],
|
| 656 |
-
leading=14,
|
| 657 |
-
spaceAfter=10,
|
| 658 |
-
)
|
| 659 |
-
source_style = ParagraphStyle(
|
| 660 |
-
"ChatSource",
|
| 661 |
-
parent=styles["BodyText"],
|
| 662 |
-
leftIndent=14,
|
| 663 |
-
leading=12,
|
| 664 |
-
spaceAfter=4,
|
| 665 |
-
)
|
| 666 |
-
|
| 667 |
-
story = [
|
| 668 |
-
Paragraph(f"Chat History - {html.escape(doc.original_name)}", styles["Title"]),
|
| 669 |
-
Spacer(1, 0.15 * inch),
|
| 670 |
-
Paragraph(f"Document: {html.escape(doc.original_name)}", metadata_style),
|
| 671 |
-
Paragraph(f"Exported at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", metadata_style),
|
| 672 |
-
Paragraph(f"Total messages: {len(messages)}", metadata_style),
|
| 673 |
-
Spacer(1, 0.2 * inch),
|
| 674 |
-
]
|
| 675 |
-
|
| 676 |
-
for msg in messages:
|
| 677 |
-
timestamp = msg.created_at.strftime("%Y-%m-%d %H:%M:%S") if msg.created_at else ""
|
| 678 |
-
role_label = "You" if msg.role == "user" else "Assistant"
|
| 679 |
-
|
| 680 |
-
story.append(Paragraph(f"<b>{html.escape(role_label)}</b>", styles["Heading3"]))
|
| 681 |
-
story.append(Paragraph(html.escape(timestamp), styles["Italic"]))
|
| 682 |
-
story.append(Paragraph(_pdf_text(msg.content), content_style))
|
| 683 |
-
|
| 684 |
-
if msg.role == "assistant" and msg.sources_json:
|
| 685 |
-
try:
|
| 686 |
-
sources = json.loads(msg.sources_json)
|
| 687 |
-
if sources:
|
| 688 |
-
story.append(Paragraph("<b>Sources:</b>", metadata_style))
|
| 689 |
-
for i, src in enumerate(sources, 1):
|
| 690 |
-
filename = html.escape(str(src.get("filename", "Unknown")))
|
| 691 |
-
page = html.escape(str(src.get("page", "?")))
|
| 692 |
-
confidence = html.escape(str(src.get("confidence", 0)))
|
| 693 |
-
story.append(
|
| 694 |
-
Paragraph(
|
| 695 |
-
f"[{i}] {filename}, Page {page} (Confidence: {confidence}%)",
|
| 696 |
-
source_style,
|
| 697 |
-
)
|
| 698 |
-
)
|
| 699 |
-
text_preview = str(src.get("text", "")).strip()
|
| 700 |
-
if text_preview:
|
| 701 |
-
story.append(Paragraph(_pdf_text(text_preview), source_style))
|
| 702 |
-
except Exception:
|
| 703 |
-
pass
|
| 704 |
-
|
| 705 |
-
story.append(Spacer(1, 0.15 * inch))
|
| 706 |
-
|
| 707 |
-
pdf.build(story)
|
| 708 |
-
return buffer.getvalue()
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
def _pdf_text(text: str) -> str:
|
| 712 |
-
"""Escape text for ReportLab paragraphs while preserving line breaks."""
|
| 713 |
-
return html.escape(text or "").replace("\n", "<br/>")
|
|
|
|
| 7 |
from datetime import datetime
|
| 8 |
from io import BytesIO
|
| 9 |
import logging
|
| 10 |
+
from typing import Optional, List
|
| 11 |
|
| 12 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 13 |
from fastapi.responses import Response, StreamingResponse
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from sqlalchemy.orm import Session
|
| 15 |
|
| 16 |
from app.auth import get_current_user
|
| 17 |
from app.database import get_db
|
| 18 |
from app.metrics import record_query_response_time
|
| 19 |
+
from app.models import User, ChatMessage, Document, SharedMessage, ChatSession
|
| 20 |
from app.rate_limit import limiter
|
| 21 |
from app.schemas import (
|
| 22 |
ChatRequest,
|
|
|
|
| 26 |
ShareAnswerResponse,
|
| 27 |
ShareLinkResponse,
|
| 28 |
SourceChunk,
|
| 29 |
+
ChatSessionCreate,
|
| 30 |
+
ChatSessionResponse,
|
| 31 |
)
|
| 32 |
|
| 33 |
logger = logging.getLogger(__name__)
|
|
|
|
| 75 |
db.commit()
|
| 76 |
|
| 77 |
return ShareLinkResponse(
|
| 78 |
+
message_id=str(message.id),
|
| 79 |
share_url=f"/share?message_id={message.id}",
|
| 80 |
)
|
| 81 |
|
| 82 |
|
| 83 |
+
@router.get("/sessions", response_model=List[ChatSessionResponse])
|
| 84 |
+
def get_chat_sessions(
|
| 85 |
+
user: User = Depends(get_current_user),
|
| 86 |
+
db: Session = Depends(get_db),
|
| 87 |
+
):
|
| 88 |
+
"""Retrieve all chat sessions for the authenticated user."""
|
| 89 |
+
sessions = (
|
| 90 |
+
db.query(ChatSession)
|
| 91 |
+
.filter(ChatSession.user_id == user.id)
|
| 92 |
+
.order_by(ChatSession.created_at.desc())
|
| 93 |
+
.all()
|
| 94 |
+
)
|
| 95 |
+
return sessions
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@router.post("/sessions", response_model=ChatSessionResponse, status_code=201)
|
| 99 |
+
def create_chat_session(
|
| 100 |
+
payload: ChatSessionCreate,
|
| 101 |
+
user: User = Depends(get_current_user),
|
| 102 |
+
db: Session = Depends(get_db),
|
| 103 |
+
):
|
| 104 |
+
"""Create a new chat session."""
|
| 105 |
+
session = ChatSession(
|
| 106 |
+
user_id=user.id,
|
| 107 |
+
title=payload.title,
|
| 108 |
+
)
|
| 109 |
+
db.add(session)
|
| 110 |
+
db.commit()
|
| 111 |
+
db.refresh(session)
|
| 112 |
+
return session
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.put("/sessions/{session_id}", response_model=ChatSessionResponse)
|
| 116 |
+
def rename_chat_session(
|
| 117 |
+
session_id: str,
|
| 118 |
+
payload: ChatSessionCreate,
|
| 119 |
+
user: User = Depends(get_current_user),
|
| 120 |
+
db: Session = Depends(get_db),
|
| 121 |
+
):
|
| 122 |
+
"""Rename an existing chat session."""
|
| 123 |
+
session = (
|
| 124 |
+
db.query(ChatSession)
|
| 125 |
+
.filter(
|
| 126 |
+
ChatSession.id == session_id,
|
| 127 |
+
ChatSession.user_id == user.id,
|
| 128 |
+
)
|
| 129 |
+
.first()
|
| 130 |
+
)
|
| 131 |
+
if not session:
|
| 132 |
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
| 133 |
+
session.title = payload.title
|
| 134 |
+
db.commit()
|
| 135 |
+
db.refresh(session)
|
| 136 |
+
return session
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.delete("/sessions/{session_id}")
|
| 140 |
+
def delete_chat_session(
|
| 141 |
+
session_id: str,
|
| 142 |
+
user: User = Depends(get_current_user),
|
| 143 |
+
db: Session = Depends(get_db),
|
| 144 |
+
):
|
| 145 |
+
"""Delete a chat session and all its messages."""
|
| 146 |
+
session = (
|
| 147 |
+
db.query(ChatSession)
|
| 148 |
+
.filter(
|
| 149 |
+
ChatSession.id == session_id,
|
| 150 |
+
ChatSession.user_id == user.id,
|
| 151 |
+
)
|
| 152 |
+
.first()
|
| 153 |
+
)
|
| 154 |
+
if not session:
|
| 155 |
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
| 156 |
+
db.delete(session)
|
| 157 |
+
db.commit()
|
| 158 |
+
return Response(status_code=204)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@router.get("/history/session/{session_id}", response_model=ChatHistoryResponse)
|
| 162 |
+
def get_session_history(
|
| 163 |
+
session_id: str,
|
| 164 |
+
user: User = Depends(get_current_user),
|
| 165 |
+
db: Session = Depends(get_db),
|
| 166 |
+
):
|
| 167 |
+
"""Retrieve chat history for a specific chat session."""
|
| 168 |
+
session = (
|
| 169 |
+
db.query(ChatSession)
|
| 170 |
+
.filter(
|
| 171 |
+
ChatSession.id == session_id,
|
| 172 |
+
ChatSession.user_id == user.id,
|
| 173 |
+
)
|
| 174 |
+
.first()
|
| 175 |
+
)
|
| 176 |
+
if not session:
|
| 177 |
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
| 178 |
+
|
| 179 |
+
messages = (
|
| 180 |
+
db.query(ChatMessage)
|
| 181 |
+
.filter(
|
| 182 |
+
ChatMessage.session_id == session_id,
|
| 183 |
+
ChatMessage.user_id == user.id,
|
| 184 |
+
)
|
| 185 |
+
.order_by(ChatMessage.created_at.asc())
|
| 186 |
+
.all()
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
formatted = []
|
| 190 |
+
for msg in messages:
|
| 191 |
+
sources = []
|
| 192 |
+
if msg.sources_json:
|
| 193 |
+
try:
|
| 194 |
+
sources = [SourceChunk(**s) for s in json.loads(msg.sources_json)]
|
| 195 |
+
except Exception:
|
| 196 |
+
pass
|
| 197 |
+
|
| 198 |
+
formatted.append(
|
| 199 |
+
ChatMessageResponse(
|
| 200 |
+
id=str(msg.id),
|
| 201 |
+
role=msg.role,
|
| 202 |
+
content=msg.content,
|
| 203 |
+
sources=sources,
|
| 204 |
+
created_at=msg.created_at,
|
| 205 |
+
)
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
return ChatHistoryResponse(messages=formatted, document_id=None)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
def generate_answer(question: str, user_id: str, document_id: Optional[str] = None, hf_token: Optional[str] = None):
|
| 212 |
from app.rag.agent import generate_answer as _generate_answer
|
| 213 |
|
|
|
|
| 228 |
user: User = Depends(get_current_user),
|
| 229 |
db: Session = Depends(get_db),
|
| 230 |
):
|
| 231 |
+
"""Ask a question with RAG retrieval (non-streaming)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
started_at = time.perf_counter()
|
| 233 |
try:
|
| 234 |
# Validate document exists if specified
|
|
|
|
| 247 |
detail=f"Document is still {doc.status}. Please wait for processing to complete.",
|
| 248 |
)
|
| 249 |
|
| 250 |
+
# Resolve or create session
|
| 251 |
+
session_id = payload.session_id
|
| 252 |
+
if not session_id:
|
| 253 |
+
session = db.query(ChatSession).filter(ChatSession.user_id == user.id).first()
|
| 254 |
+
if not session:
|
| 255 |
+
session = ChatSession(user_id=user.id, title="Default Chat")
|
| 256 |
+
db.add(session)
|
| 257 |
+
db.commit()
|
| 258 |
+
db.refresh(session)
|
| 259 |
+
session_id = session.id
|
| 260 |
+
|
| 261 |
result = generate_answer(
|
| 262 |
question=payload.question,
|
| 263 |
user_id=user.id,
|
|
|
|
| 266 |
)
|
| 267 |
|
| 268 |
# Save to chat history
|
| 269 |
+
_save_message(db, user.id, payload.document_id, "user", payload.question, session_id=session_id)
|
| 270 |
+
_save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"], session_id=session_id)
|
| 271 |
|
| 272 |
return ChatResponse(
|
| 273 |
answer=result["answer"],
|
|
|
|
| 286 |
user: User = Depends(get_current_user),
|
| 287 |
db: Session = Depends(get_db),
|
| 288 |
):
|
| 289 |
+
"""Ask a question with Server-Sent Events (SSE) streaming response."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
# Validate document
|
| 291 |
if payload.document_id:
|
| 292 |
doc = db.query(Document).filter(
|
|
|
|
| 305 |
|
| 306 |
started_at = time.perf_counter()
|
| 307 |
|
| 308 |
+
# Resolve or create session
|
| 309 |
+
session_id = payload.session_id
|
| 310 |
+
if not session_id:
|
| 311 |
+
session = db.query(ChatSession).filter(ChatSession.user_id == user.id).first()
|
| 312 |
+
if not session:
|
| 313 |
+
session = ChatSession(user_id=user.id, title="Default Chat")
|
| 314 |
+
db.add(session)
|
| 315 |
+
db.commit()
|
| 316 |
+
db.refresh(session)
|
| 317 |
+
session_id = session.id
|
| 318 |
+
|
| 319 |
# Save user message immediately
|
| 320 |
+
_save_message(db, user.id, payload.document_id, "user", payload.question, session_id=session_id)
|
| 321 |
|
| 322 |
# Stream response
|
| 323 |
def event_stream():
|
|
|
|
| 348 |
from app.database import SessionLocal
|
| 349 |
save_db = SessionLocal()
|
| 350 |
try:
|
| 351 |
+
_save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources, session_id=session_id)
|
| 352 |
finally:
|
| 353 |
save_db.close()
|
| 354 |
finally:
|
|
|
|
| 371 |
user: User = Depends(get_current_user),
|
| 372 |
db: Session = Depends(get_db),
|
| 373 |
):
|
| 374 |
+
"""Retrieve the complete chat history for a specific document."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
messages = (
|
| 376 |
db.query(ChatMessage)
|
| 377 |
.filter(
|
|
|
|
| 392 |
pass
|
| 393 |
|
| 394 |
formatted.append(ChatMessageResponse(
|
| 395 |
+
id=str(msg.id),
|
| 396 |
role=msg.role,
|
| 397 |
content=msg.content,
|
| 398 |
sources=sources,
|
|
|
|
| 409 |
token: Optional[str] = None,
|
| 410 |
db: Session = Depends(get_db),
|
| 411 |
):
|
| 412 |
+
"""Export the chat history for a document as a downloadable file."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
from app.auth import decode_token as _decode
|
| 414 |
|
| 415 |
# Resolve user from query-param token (browser download links can't set headers)
|
|
|
|
| 456 |
media_type = "text/plain"
|
| 457 |
extension = "txt"
|
| 458 |
else:
|
| 459 |
+
from app.routes.chat_export import format_pdf as _format_pdf
|
| 460 |
content = _format_pdf(doc, messages)
|
| 461 |
media_type = "application/pdf"
|
| 462 |
extension = "pdf"
|
|
|
|
| 479 |
user: User = Depends(get_current_user),
|
| 480 |
db: Session = Depends(get_db),
|
| 481 |
):
|
| 482 |
+
"""Delete all chat messages associated with a specific document."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
db.query(ChatMessage).filter(
|
| 484 |
ChatMessage.user_id == user.id,
|
| 485 |
ChatMessage.document_id == document_id,
|
|
|
|
| 496 |
role: str,
|
| 497 |
content: str,
|
| 498 |
sources: list = None,
|
| 499 |
+
session_id: Optional[str] = None,
|
| 500 |
):
|
| 501 |
+
"""Save a chat message to the database."""
|
| 502 |
+
if not session_id:
|
| 503 |
+
session = db.query(ChatSession).filter(ChatSession.user_id == user_id).first()
|
| 504 |
+
if not session:
|
| 505 |
+
session = ChatSession(user_id=user_id, title="Default Chat")
|
| 506 |
+
db.add(session)
|
| 507 |
+
db.commit()
|
| 508 |
+
db.refresh(session)
|
| 509 |
+
session_id = session.id
|
| 510 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
msg = ChatMessage(
|
| 512 |
user_id=user_id,
|
| 513 |
document_id=document_id,
|
| 514 |
+
session_id=session_id,
|
| 515 |
role=role,
|
| 516 |
content=content,
|
| 517 |
sources_json=json.dumps(sources) if sources else None,
|
|
|
|
| 530 |
sources = []
|
| 531 |
|
| 532 |
return ShareAnswerResponse(
|
| 533 |
+
id=str(message.id),
|
| 534 |
content=message.content,
|
| 535 |
created_at=message.created_at,
|
| 536 |
sources=sources,
|
|
|
|
| 538 |
|
| 539 |
|
| 540 |
def _format_markdown(doc, messages) -> str:
|
| 541 |
+
"""Format chat history as a Markdown document."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
lines = [
|
| 543 |
f"# Chat History — {doc.original_name}",
|
| 544 |
"",
|
| 545 |
f"**Document:** {doc.original_name} ",
|
| 546 |
+
f"**Exported at:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ",
|
| 547 |
f"**Total messages:** {len(messages)}",
|
| 548 |
"",
|
| 549 |
"---",
|
|
|
|
| 560 |
lines.append(msg.content)
|
| 561 |
lines.append("")
|
| 562 |
|
|
|
|
| 563 |
if msg.role == "assistant" and msg.sources_json:
|
| 564 |
try:
|
| 565 |
sources = json.loads(msg.sources_json)
|
|
|
|
| 585 |
|
| 586 |
|
| 587 |
def _format_plaintext(doc, messages) -> str:
|
| 588 |
+
"""Format chat history as a plain text document."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
lines = [
|
| 590 |
f"Chat History — {doc.original_name}",
|
| 591 |
+
f"Exported at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
| 592 |
f"Total messages: {len(messages)}",
|
| 593 |
"=" * 60,
|
| 594 |
"",
|
|
|
|
| 601 |
lines.append(f"[{role_label}] ({timestamp})")
|
| 602 |
lines.append(msg.content)
|
| 603 |
|
|
|
|
| 604 |
if msg.role == "assistant" and msg.sources_json:
|
| 605 |
try:
|
| 606 |
sources = json.loads(msg.sources_json)
|
|
|
|
| 618 |
lines.append("")
|
| 619 |
|
| 620 |
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/schemas.py
CHANGED
|
@@ -146,6 +146,7 @@ class AdminStatsResponse(BaseModel):
|
|
| 146 |
class ChatRequest(BaseModel):
|
| 147 |
question: str = Field(..., min_length=1, max_length=2000)
|
| 148 |
document_id: Optional[str] = None
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
class SourceChunk(BaseModel):
|
|
@@ -192,5 +193,20 @@ class ShareLinkResponse(BaseModel):
|
|
| 192 |
share_url: str
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
# Rebuild models for forward references
|
| 196 |
TokenResponse.model_rebuild()
|
|
|
|
| 146 |
class ChatRequest(BaseModel):
|
| 147 |
question: str = Field(..., min_length=1, max_length=2000)
|
| 148 |
document_id: Optional[str] = None
|
| 149 |
+
session_id: Optional[str] = None
|
| 150 |
|
| 151 |
|
| 152 |
class SourceChunk(BaseModel):
|
|
|
|
| 193 |
share_url: str
|
| 194 |
|
| 195 |
|
| 196 |
+
# ── Chat Session ──────────────────────────────────────
|
| 197 |
+
|
| 198 |
+
class ChatSessionCreate(BaseModel):
|
| 199 |
+
title: str = Field(..., min_length=1, max_length=255)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class ChatSessionResponse(BaseModel):
|
| 203 |
+
id: str
|
| 204 |
+
title: str
|
| 205 |
+
created_at: datetime
|
| 206 |
+
|
| 207 |
+
class Config:
|
| 208 |
+
from_attributes = True
|
| 209 |
+
|
| 210 |
+
|
| 211 |
# Rebuild models for forward references
|
| 212 |
TokenResponse.model_rebuild()
|
frontend/src/app/dashboard/page.tsx
CHANGED
|
@@ -7,8 +7,8 @@ import { useAuth } from "@/lib/auth";
|
|
| 7 |
import { api, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
|
| 8 |
import Header from "@/components/layout/Header";
|
| 9 |
import DocumentSidebar from "@/components/document/DocumentSidebar";
|
|
|
|
| 10 |
import ChatPanel from "@/components/chat/ChatPanel";
|
| 11 |
-
|
| 12 |
function PDFViewerSkeleton() {
|
| 13 |
return (
|
| 14 |
<div
|
|
@@ -164,6 +164,9 @@ export default function DashboardPage() {
|
|
| 164 |
</div>
|
| 165 |
)}
|
| 166 |
|
|
|
|
|
|
|
|
|
|
| 167 |
{/* ── Center: Chat Panel ──────────────────────────────────── */}
|
| 168 |
<div className="flex-1 min-w-0 flex flex-col">
|
| 169 |
<ChatPanel
|
|
|
|
| 7 |
import { api, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
|
| 8 |
import Header from "@/components/layout/Header";
|
| 9 |
import DocumentSidebar from "@/components/document/DocumentSidebar";
|
| 10 |
+
import ChatSessionSidebar from "@/components/chat/ChatSessionSidebar";
|
| 11 |
import ChatPanel from "@/components/chat/ChatPanel";
|
|
|
|
| 12 |
function PDFViewerSkeleton() {
|
| 13 |
return (
|
| 14 |
<div
|
|
|
|
| 164 |
</div>
|
| 165 |
)}
|
| 166 |
|
| 167 |
+
{/* ── Left-Center: Chat Sessions Sidebar ──── */}
|
| 168 |
+
<ChatSessionSidebar />
|
| 169 |
+
|
| 170 |
{/* ── Center: Chat Panel ──────────────────────────────────── */}
|
| 171 |
<div className="flex-1 min-w-0 flex flex-col">
|
| 172 |
<ChatPanel
|
frontend/src/components/chat/ChatPanel.tsx
CHANGED
|
@@ -22,11 +22,13 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 22 |
const input = useChatStore((state) => state.input);
|
| 23 |
const streaming = useChatStore((state) => state.streaming);
|
| 24 |
const isTyping = useChatStore((state) => state.isTyping);
|
|
|
|
| 25 |
const setMessages = useChatStore((state) => state.setMessages);
|
| 26 |
const setInput = useChatStore((state) => state.setInput);
|
| 27 |
const setStreaming = useChatStore((state) => state.setStreaming);
|
| 28 |
const setIsTyping = useChatStore((state) => state.setIsTyping);
|
| 29 |
const resetChat = useChatStore((state) => state.resetChat);
|
|
|
|
| 30 |
const [showExportMenu, setShowExportMenu] = useState(false);
|
| 31 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 32 |
const bottomRef = useRef<HTMLDivElement>(null);
|
|
@@ -61,8 +63,13 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 61 |
};
|
| 62 |
}, [resetChat]);
|
| 63 |
|
| 64 |
-
// Load history on
|
| 65 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
if (!activeDoc) {
|
| 67 |
prevDocId.current = null;
|
| 68 |
setMessages([]);
|
|
@@ -100,7 +107,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 100 |
return () => {
|
| 101 |
cancelled = true;
|
| 102 |
};
|
| 103 |
-
}, [activeDoc,
|
| 104 |
|
| 105 |
const handleSend = async () => {
|
| 106 |
if (!input.trim() || streaming) return;
|
|
@@ -128,6 +135,7 @@ export default function ChatPanel({ activeDoc, onCitationClick }: Props) {
|
|
| 128 |
const stream = api.streamPost("/api/v1/chat/ask/stream", {
|
| 129 |
question,
|
| 130 |
document_id: activeDoc?.id || null,
|
|
|
|
| 131 |
});
|
| 132 |
|
| 133 |
for await (const event of stream) {
|
|
|
|
| 22 |
const input = useChatStore((state) => state.input);
|
| 23 |
const streaming = useChatStore((state) => state.streaming);
|
| 24 |
const isTyping = useChatStore((state) => state.isTyping);
|
| 25 |
+
const activeSessionId = useChatStore((state) => state.activeSessionId);
|
| 26 |
const setMessages = useChatStore((state) => state.setMessages);
|
| 27 |
const setInput = useChatStore((state) => state.setInput);
|
| 28 |
const setStreaming = useChatStore((state) => state.setStreaming);
|
| 29 |
const setIsTyping = useChatStore((state) => state.setIsTyping);
|
| 30 |
const resetChat = useChatStore((state) => state.resetChat);
|
| 31 |
+
const fetchSessionHistory = useChatStore((state) => state.fetchSessionHistory);
|
| 32 |
const [showExportMenu, setShowExportMenu] = useState(false);
|
| 33 |
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 34 |
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 63 |
};
|
| 64 |
}, [resetChat]);
|
| 65 |
|
| 66 |
+
// Load history on activeSessionId or fallback to activeDoc change
|
| 67 |
useEffect(() => {
|
| 68 |
+
if (activeSessionId) {
|
| 69 |
+
fetchSessionHistory(activeSessionId);
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
if (!activeDoc) {
|
| 74 |
prevDocId.current = null;
|
| 75 |
setMessages([]);
|
|
|
|
| 107 |
return () => {
|
| 108 |
cancelled = true;
|
| 109 |
};
|
| 110 |
+
}, [activeSessionId, activeDoc, fetchSessionHistory, setMessages]);
|
| 111 |
|
| 112 |
const handleSend = async () => {
|
| 113 |
if (!input.trim() || streaming) return;
|
|
|
|
| 135 |
const stream = api.streamPost("/api/v1/chat/ask/stream", {
|
| 136 |
question,
|
| 137 |
document_id: activeDoc?.id || null,
|
| 138 |
+
session_id: activeSessionId,
|
| 139 |
});
|
| 140 |
|
| 141 |
for await (const event of stream) {
|
frontend/src/components/chat/ChatSessionSidebar.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import { Plus, Edit2, Trash2, MessageSquare, ChevronLeft } from "lucide-react";
|
| 5 |
+
import { useChatStore, type ChatSession } from "@/store/chat-store";
|
| 6 |
+
import { Button } from "@/components/ui/button";
|
| 7 |
+
import { Input } from "@/components/ui/input";
|
| 8 |
+
import { cn } from "@/lib/utils";
|
| 9 |
+
|
| 10 |
+
export default function ChatSessionSidebar() {
|
| 11 |
+
const sessions = useChatStore((state) => state.sessions);
|
| 12 |
+
const activeSessionId = useChatStore((state) => state.activeSessionId);
|
| 13 |
+
const fetchSessions = useChatStore((state) => state.fetchSessions);
|
| 14 |
+
const createSession = useChatStore((state) => state.createSession);
|
| 15 |
+
const renameSession = useChatStore((state) => state.renameSession);
|
| 16 |
+
const deleteSession = useChatStore((state) => state.deleteSession);
|
| 17 |
+
const setActiveSessionId = useChatStore((state) => state.setActiveSessionId);
|
| 18 |
+
const fetchSessionHistory = useChatStore((state) => state.fetchSessionHistory);
|
| 19 |
+
|
| 20 |
+
const [isOpen, setIsOpen] = useState(true);
|
| 21 |
+
const [editingId, setEditingId] = useState<string | null>(null);
|
| 22 |
+
const [editTitle, setEditTitle] = useState("");
|
| 23 |
+
const [creating, setCreating] = useState(false);
|
| 24 |
+
|
| 25 |
+
// Load sessions on mount
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
fetchSessions();
|
| 28 |
+
}, [fetchSessions]);
|
| 29 |
+
|
| 30 |
+
const handleCreate = async () => {
|
| 31 |
+
if (creating) return;
|
| 32 |
+
setCreating(true);
|
| 33 |
+
try {
|
| 34 |
+
const defaultTitle = `Chat ${sessions.length + 1}`;
|
| 35 |
+
const newId = await createSession(defaultTitle);
|
| 36 |
+
setEditingId(newId);
|
| 37 |
+
setEditTitle(defaultTitle);
|
| 38 |
+
} catch (err) {
|
| 39 |
+
console.error(err);
|
| 40 |
+
} finally {
|
| 41 |
+
setCreating(false);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleStartRename = (session: ChatSession, e: React.MouseEvent) => {
|
| 46 |
+
e.stopPropagation();
|
| 47 |
+
setEditingId(session.id);
|
| 48 |
+
setEditTitle(session.title);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const handleSaveRename = async (id: string, e?: React.FormEvent) => {
|
| 52 |
+
if (e) e.preventDefault();
|
| 53 |
+
if (!editTitle.trim()) {
|
| 54 |
+
setEditingId(null);
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
try {
|
| 58 |
+
await renameSession(id, editTitle.trim());
|
| 59 |
+
} catch (err) {
|
| 60 |
+
console.error(err);
|
| 61 |
+
} finally {
|
| 62 |
+
setEditingId(null);
|
| 63 |
+
}
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
| 67 |
+
e.stopPropagation();
|
| 68 |
+
if (confirm("Are you sure you want to delete this chat session?")) {
|
| 69 |
+
try {
|
| 70 |
+
await deleteSession(id);
|
| 71 |
+
} catch (err) {
|
| 72 |
+
console.error(err);
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
const handleSelectSession = async (id: string) => {
|
| 78 |
+
setActiveSessionId(id);
|
| 79 |
+
await fetchSessionHistory(id);
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
return (
|
| 83 |
+
<div className={cn("relative flex h-full border-r border-border/50 bg-card/20 select-none transition-all duration-300", isOpen ? "w-64" : "w-0")}>
|
| 84 |
+
<div className={cn("flex flex-col h-full w-full overflow-hidden transition-opacity duration-200", isOpen ? "opacity-100" : "opacity-0 pointer-events-none")}>
|
| 85 |
+
{/* Sidebar Header */}
|
| 86 |
+
<div className="flex items-center justify-between p-3 border-b border-border/50 shrink-0 bg-card/45">
|
| 87 |
+
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Chat Sessions</span>
|
| 88 |
+
<Button
|
| 89 |
+
onClick={handleCreate}
|
| 90 |
+
variant="outline"
|
| 91 |
+
size="icon"
|
| 92 |
+
className="h-7 w-7 bg-background/50 hover:bg-accent hover:text-accent-foreground"
|
| 93 |
+
disabled={creating}
|
| 94 |
+
>
|
| 95 |
+
<Plus className="w-4 h-4" />
|
| 96 |
+
</Button>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{/* Sessions List */}
|
| 100 |
+
<div className="flex-1 overflow-y-auto p-2 space-y-1 scrollbar-thin">
|
| 101 |
+
{sessions.length === 0 ? (
|
| 102 |
+
<div className="text-center py-8 px-4">
|
| 103 |
+
<p className="text-xs text-muted-foreground">No chat sessions. Click "+" to start a new chat.</p>
|
| 104 |
+
</div>
|
| 105 |
+
) : (
|
| 106 |
+
sessions.map((session) => {
|
| 107 |
+
const isActive = session.id === activeSessionId;
|
| 108 |
+
const isEditing = session.id === editingId;
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div
|
| 112 |
+
key={session.id}
|
| 113 |
+
onClick={() => !isEditing && handleSelectSession(session.id)}
|
| 114 |
+
className={cn(
|
| 115 |
+
"group flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-all duration-200 cursor-pointer border",
|
| 116 |
+
isActive
|
| 117 |
+
? "bg-accent/80 border-accent text-accent-foreground shadow-sm"
|
| 118 |
+
: "border-transparent hover:bg-card/60 hover:text-foreground text-muted-foreground"
|
| 119 |
+
)}
|
| 120 |
+
>
|
| 121 |
+
<div className="flex items-center gap-2 min-w-0 flex-1">
|
| 122 |
+
<MessageSquare className={cn("w-4 h-4 shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
|
| 123 |
+
|
| 124 |
+
{isEditing ? (
|
| 125 |
+
<form
|
| 126 |
+
onSubmit={(e) => handleSaveRename(session.id, e)}
|
| 127 |
+
className="flex items-center gap-1 w-full"
|
| 128 |
+
onClick={(e) => e.stopPropagation()}
|
| 129 |
+
>
|
| 130 |
+
<Input
|
| 131 |
+
value={editTitle}
|
| 132 |
+
onChange={(e) => setEditTitle(e.target.value)}
|
| 133 |
+
className="h-6 text-xs px-1 py-0 bg-background/50 border-input w-full"
|
| 134 |
+
autoFocus
|
| 135 |
+
onBlur={() => handleSaveRename(session.id)}
|
| 136 |
+
/>
|
| 137 |
+
</form>
|
| 138 |
+
) : (
|
| 139 |
+
<span className="truncate text-xs font-medium">{session.title}</span>
|
| 140 |
+
)}
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
{!isEditing && (
|
| 144 |
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-150 shrink-0 ml-1">
|
| 145 |
+
<Button
|
| 146 |
+
variant="ghost"
|
| 147 |
+
size="icon"
|
| 148 |
+
className="h-5 w-5 rounded-md hover:bg-background/80"
|
| 149 |
+
onClick={(e) => handleStartRename(session, e)}
|
| 150 |
+
>
|
| 151 |
+
<Edit2 className="w-3 h-3" />
|
| 152 |
+
</Button>
|
| 153 |
+
<Button
|
| 154 |
+
variant="ghost"
|
| 155 |
+
size="icon"
|
| 156 |
+
className="h-5 w-5 rounded-md hover:bg-destructive/10 hover:text-destructive"
|
| 157 |
+
onClick={(e) => handleDelete(session.id, e)}
|
| 158 |
+
>
|
| 159 |
+
<Trash2 className="w-3 h-3" />
|
| 160 |
+
</Button>
|
| 161 |
+
</div>
|
| 162 |
+
)}
|
| 163 |
+
</div>
|
| 164 |
+
);
|
| 165 |
+
})
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
|
| 170 |
+
{/* Collapse Toggle Button */}
|
| 171 |
+
<Button
|
| 172 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 173 |
+
variant="ghost"
|
| 174 |
+
size="icon"
|
| 175 |
+
className={cn(
|
| 176 |
+
"absolute -right-3 top-1/2 -translate-y-1/2 z-40 h-6 w-6 rounded-full border border-border bg-background shadow-md hover:bg-accent hover:text-accent-foreground",
|
| 177 |
+
!isOpen && "right-auto -left-3 rotate-180"
|
| 178 |
+
)}
|
| 179 |
+
>
|
| 180 |
+
<ChevronLeft className="w-3.5 h-3.5" />
|
| 181 |
+
</Button>
|
| 182 |
+
</div>
|
| 183 |
+
);
|
| 184 |
+
}
|
frontend/src/store/chat-store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { create } from "zustand";
|
|
|
|
| 4 |
|
| 5 |
export interface SourceChunk {
|
| 6 |
text: string;
|
|
@@ -18,6 +19,12 @@ export interface ChatMsg {
|
|
| 18 |
isStreaming?: boolean;
|
| 19 |
}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
type Setter<T> = T | ((prev: T) => T);
|
| 22 |
|
| 23 |
interface ChatStore {
|
|
@@ -25,21 +32,32 @@ interface ChatStore {
|
|
| 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) }));
|
|
@@ -57,12 +75,97 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|
| 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 |
}));
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { create } from "zustand";
|
| 4 |
+
import { api } from "@/lib/api";
|
| 5 |
|
| 6 |
export interface SourceChunk {
|
| 7 |
text: string;
|
|
|
|
| 19 |
isStreaming?: boolean;
|
| 20 |
}
|
| 21 |
|
| 22 |
+
export interface ChatSession {
|
| 23 |
+
id: string;
|
| 24 |
+
title: string;
|
| 25 |
+
created_at: string;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
type Setter<T> = T | ((prev: T) => T);
|
| 29 |
|
| 30 |
interface ChatStore {
|
|
|
|
| 32 |
input: string;
|
| 33 |
streaming: boolean;
|
| 34 |
isTyping: boolean;
|
| 35 |
+
sessions: ChatSession[];
|
| 36 |
+
activeSessionId: string | null;
|
| 37 |
setMessages: (value: Setter<ChatMsg[]>) => void;
|
| 38 |
setInput: (value: Setter<string>) => void;
|
| 39 |
setStreaming: (value: Setter<boolean>) => void;
|
| 40 |
setIsTyping: (value: Setter<boolean>) => void;
|
| 41 |
+
setSessions: (value: Setter<ChatSession[]>) => void;
|
| 42 |
+
setActiveSessionId: (value: Setter<string | null>) => void;
|
| 43 |
+
fetchSessions: () => Promise<void>;
|
| 44 |
+
createSession: (title: string) => Promise<string>;
|
| 45 |
+
renameSession: (id: string, title: string) => Promise<void>;
|
| 46 |
+
deleteSession: (id: string) => Promise<void>;
|
| 47 |
+
fetchSessionHistory: (id: string) => Promise<void>;
|
| 48 |
resetChat: () => void;
|
| 49 |
}
|
| 50 |
|
| 51 |
const resolveValue = <T,>(value: Setter<T>, current: T): T =>
|
| 52 |
typeof value === "function" ? (value as (prev: T) => T)(current) : value;
|
| 53 |
|
| 54 |
+
export const useChatStore = create<ChatStore>((set, get) => ({
|
| 55 |
messages: [],
|
| 56 |
input: "",
|
| 57 |
streaming: false,
|
| 58 |
isTyping: false,
|
| 59 |
+
sessions: [],
|
| 60 |
+
activeSessionId: null,
|
| 61 |
|
| 62 |
setMessages(value) {
|
| 63 |
set((state) => ({ messages: resolveValue(value, state.messages) }));
|
|
|
|
| 75 |
set((state) => ({ isTyping: resolveValue(value, state.isTyping) }));
|
| 76 |
},
|
| 77 |
|
| 78 |
+
setSessions(value) {
|
| 79 |
+
set((state) => ({ sessions: resolveValue(value, state.sessions) }));
|
| 80 |
+
},
|
| 81 |
+
|
| 82 |
+
setActiveSessionId(value) {
|
| 83 |
+
set((state) => ({ activeSessionId: resolveValue(value, state.activeSessionId) }));
|
| 84 |
+
},
|
| 85 |
+
|
| 86 |
+
async fetchSessions() {
|
| 87 |
+
try {
|
| 88 |
+
const data = await api.get<ChatSession[]>("/api/v1/chat/sessions");
|
| 89 |
+
set({ sessions: data });
|
| 90 |
+
if (data.length > 0 && !get().activeSessionId) {
|
| 91 |
+
set({ activeSessionId: data[0].id });
|
| 92 |
+
await get().fetchSessionHistory(data[0].id);
|
| 93 |
+
}
|
| 94 |
+
} catch (err) {
|
| 95 |
+
console.error("Failed to fetch chat sessions:", err);
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
async createSession(title) {
|
| 100 |
+
try {
|
| 101 |
+
const session = await api.post<ChatSession>("/api/v1/chat/sessions", { title });
|
| 102 |
+
set((state) => ({
|
| 103 |
+
sessions: [session, ...state.sessions],
|
| 104 |
+
activeSessionId: session.id,
|
| 105 |
+
messages: [],
|
| 106 |
+
}));
|
| 107 |
+
return session.id;
|
| 108 |
+
} catch (err) {
|
| 109 |
+
console.error("Failed to create chat session:", err);
|
| 110 |
+
throw err;
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
|
| 114 |
+
async renameSession(id, title) {
|
| 115 |
+
try {
|
| 116 |
+
const updated = await api.put<ChatSession>(`/api/v1/chat/sessions/${id}`, { title });
|
| 117 |
+
set((state) => ({
|
| 118 |
+
sessions: state.sessions.map((s) => (s.id === id ? updated : s)),
|
| 119 |
+
}));
|
| 120 |
+
} catch (err) {
|
| 121 |
+
console.error("Failed to rename chat session:", err);
|
| 122 |
+
throw err;
|
| 123 |
+
}
|
| 124 |
+
},
|
| 125 |
+
|
| 126 |
+
async deleteSession(id) {
|
| 127 |
+
try {
|
| 128 |
+
await api.delete(`/api/v1/chat/sessions/${id}`);
|
| 129 |
+
set((state) => {
|
| 130 |
+
const nextSessions = state.sessions.filter((s) => s.id !== id);
|
| 131 |
+
let nextActiveId = state.activeSessionId;
|
| 132 |
+
if (state.activeSessionId === id) {
|
| 133 |
+
nextActiveId = nextSessions.length > 0 ? nextSessions[0].id : null;
|
| 134 |
+
}
|
| 135 |
+
return {
|
| 136 |
+
sessions: nextSessions,
|
| 137 |
+
activeSessionId: nextActiveId,
|
| 138 |
+
};
|
| 139 |
+
});
|
| 140 |
+
const activeId = get().activeSessionId;
|
| 141 |
+
if (activeId) {
|
| 142 |
+
await get().fetchSessionHistory(activeId);
|
| 143 |
+
} else {
|
| 144 |
+
set({ messages: [] });
|
| 145 |
+
}
|
| 146 |
+
} catch (err) {
|
| 147 |
+
console.error("Failed to delete chat session:", err);
|
| 148 |
+
throw err;
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
|
| 152 |
+
async fetchSessionHistory(id) {
|
| 153 |
+
try {
|
| 154 |
+
const data = await api.get<{ messages: ChatMsg[] }>(`/api/v1/chat/history/session/${id}`);
|
| 155 |
+
set({ messages: data.messages });
|
| 156 |
+
} catch (err) {
|
| 157 |
+
console.error("Failed to fetch session history:", err);
|
| 158 |
+
}
|
| 159 |
+
},
|
| 160 |
+
|
| 161 |
resetChat() {
|
| 162 |
set({
|
| 163 |
messages: [],
|
| 164 |
input: "",
|
| 165 |
streaming: false,
|
| 166 |
isTyping: false,
|
| 167 |
+
sessions: [],
|
| 168 |
+
activeSessionId: null,
|
| 169 |
});
|
| 170 |
},
|
| 171 |
}));
|