Paramjit Singh commited on
Commit
397aac4
·
unverified ·
2 Parent(s): f815bcd1229b2e

Merge pull request #237 from Exodus2004/feat/chat-sessions

Browse files
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
- Creates a `ChatMessage` record with the provided user, document,
471
- role, content, and optional source metadata. The message is added to
472
- the session and committed immediately. The database session must be
473
- managed by the caller (e.g., closed after use).
474
-
475
- Args:
476
- user_id: The ID of the authenticated user.
477
- document_id: Optional document ID that the message pertains to.
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:** {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ",
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: {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
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 doc change
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, resetChat, setMessages]);
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 &quot;+&quot; 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
  }));