mohsin-devs Cursor commited on
Commit
b6e2e3b
·
1 Parent(s): 526fdb9

feat: persistent AI chat with multi-session history sidebar

Browse files
backend/app/ai/chat.py CHANGED
@@ -1,36 +1,180 @@
1
  import json
2
  import os
3
- from threading import Lock
4
  from sqlalchemy.orm import Session
5
- from app.database.models import User, Account, Transaction, Goal, Investment, Subscription
 
 
 
 
 
6
  from app.ai.behavior import analyze_spending_behavior
7
  from app.ai.coaching import calculate_financial_health_score
8
 
9
- # Thread-safe chatbot memory storage
 
 
 
 
 
 
 
 
 
 
 
10
  class ChatMemoryManager:
11
- def __init__(self):
12
- self._history = {}
13
- self._lock = Lock()
14
-
15
- def get_history(self, user_id: str):
16
- with self._lock:
17
- if user_id not in self._history:
18
- self._history[user_id] = []
19
- return self._history[user_id]
20
-
21
- def add_message(self, user_id: str, role: str, content: str):
22
- with self._lock:
23
- if user_id not in self._history:
24
- self._history[user_id] = []
25
- self._history[user_id].append({"role": role, "content": content})
26
- # Limit history to last 12 messages (6 rounds)
27
- if len(self._history[user_id]) > 12:
28
- self._history[user_id] = self._history[user_id][-12:]
29
-
30
- def clear_history(self, user_id: str):
31
- with self._lock:
32
- if user_id in self._history:
33
- self._history[user_id] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  chat_memory = ChatMemoryManager()
36
 
@@ -185,21 +329,25 @@ def get_offline_chat_fallback(db: Session, user_id: str, prompt: str) -> str:
185
  "Audit duplicate subscriptions and automate goal savings to enhance your trajectory."
186
  )
187
 
188
- def get_chat_response(db: Session, user_id: str, prompt: str) -> str:
 
 
189
  """
190
- Returns an HTTP conversational response grounded in database context.
191
  """
192
  from app.ai.ollama_integration import has_active_ai_backend
193
 
 
 
194
  if not has_active_ai_backend():
195
  fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
196
- chat_memory.add_message(user_id, "user", prompt)
197
- chat_memory.add_message(user_id, "assistant", fallback_msg)
198
- return fallback_msg
199
 
200
  # Build full message list: system prompt with real account data + history + new message
201
  sys_prompt = get_contextual_system_prompt(db, user_id)
202
- history = chat_memory.get_history(user_id)
203
 
204
  full_messages = [{"role": "system", "content": sys_prompt}]
205
  for msg in history:
@@ -250,20 +398,24 @@ def get_chat_response(db: Session, user_id: str, prompt: str) -> str:
250
  if not response_content:
251
  response_content = get_offline_chat_fallback(db, user_id, prompt)
252
 
253
- chat_memory.add_message(user_id, "user", prompt)
254
- chat_memory.add_message(user_id, "assistant", response_content)
255
- return response_content
256
 
257
- def stream_chat_response(db: Session, user_id: str, prompt: str):
 
 
258
  """
259
  Generates streaming chunks for WebSocket or HTTP SSE.
260
  """
261
  from app.ai.ollama_integration import has_active_ai_backend
262
 
 
 
263
  if not has_active_ai_backend():
264
  fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
265
- chat_memory.add_message(user_id, "user", prompt)
266
- chat_memory.add_message(user_id, "assistant", fallback_msg)
267
  import time
268
  for word in fallback_msg.split(" "):
269
  yield word + " "
@@ -271,14 +423,14 @@ def stream_chat_response(db: Session, user_id: str, prompt: str):
271
  return
272
 
273
  sys_prompt = get_contextual_system_prompt(db, user_id)
274
- history = chat_memory.get_history(user_id)
275
 
276
  full_messages = [{"role": "system", "content": sys_prompt}]
277
  for msg in history:
278
  full_messages.append({"role": msg["role"], "content": msg["content"]})
279
  full_messages.append({"role": "user", "content": prompt})
280
 
281
- chat_memory.add_message(user_id, "user", prompt)
282
 
283
  OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
284
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "")
@@ -301,7 +453,7 @@ def stream_chat_response(db: Session, user_id: str, prompt: str):
301
  if content:
302
  complete_reply += content
303
  yield content
304
- chat_memory.add_message(user_id, "assistant", complete_reply)
305
  return
306
  except Exception as e:
307
  print(f"OpenAI streaming error: {e}")
@@ -322,7 +474,7 @@ def stream_chat_response(db: Session, user_id: str, prompt: str):
322
  if content:
323
  complete_reply += content
324
  yield content
325
- chat_memory.add_message(user_id, "assistant", complete_reply)
326
  return
327
  except Exception as e:
328
  print(f"Groq streaming error: {e}")
@@ -333,9 +485,9 @@ def stream_chat_response(db: Session, user_id: str, prompt: str):
333
  if chunk:
334
  complete_reply += chunk
335
  yield chunk
336
- chat_memory.add_message(user_id, "assistant", complete_reply)
337
  except Exception as e:
338
  print(f"Ollama streaming error: {e}")
339
  fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
340
  yield fallback_msg
341
- chat_memory.add_message(user_id, "assistant", fallback_msg)
 
1
  import json
2
  import os
 
3
  from sqlalchemy.orm import Session
4
+ from datetime import datetime, timezone
5
+
6
+ from app.database.models import (
7
+ User, Account, Transaction, Goal, Investment, Subscription,
8
+ ChatMessage, ChatSession,
9
+ )
10
  from app.ai.behavior import analyze_spending_behavior
11
  from app.ai.coaching import calculate_financial_health_score
12
 
13
+ CONTEXT_LIMIT = 12
14
+ MESSAGES_PER_SESSION_LIMIT = 200
15
+ SESSIONS_PER_USER_LIMIT = 50
16
+
17
+
18
+ def _title_from_prompt(text: str) -> str:
19
+ cleaned = " ".join(text.strip().split())
20
+ if len(cleaned) <= 48:
21
+ return cleaned or "New chat"
22
+ return cleaned[:48] + "…"
23
+
24
+
25
  class ChatMemoryManager:
26
+ """Session-scoped chat persistence with multi-conversation support."""
27
+
28
+ def get_session(self, db: Session, user_id: str, session_id: str) -> ChatSession | None:
29
+ return (
30
+ db.query(ChatSession)
31
+ .filter(ChatSession.id == session_id, ChatSession.user_id == user_id)
32
+ .first()
33
+ )
34
+
35
+ def create_session(self, db: Session, user_id: str, title: str = "New chat") -> ChatSession:
36
+ session = ChatSession(user_id=user_id, title=title or "New chat")
37
+ db.add(session)
38
+ db.commit()
39
+ db.refresh(session)
40
+ self._trim_old_sessions(db, user_id)
41
+ return session
42
+
43
+ def list_sessions(self, db: Session, user_id: str, limit: int = 30) -> list[dict]:
44
+ limit = min(max(limit, 1), SESSIONS_PER_USER_LIMIT)
45
+ sessions = (
46
+ db.query(ChatSession)
47
+ .filter(ChatSession.user_id == user_id)
48
+ .order_by(ChatSession.updated_at.desc())
49
+ .limit(limit)
50
+ .all()
51
+ )
52
+ result = []
53
+ for s in sessions:
54
+ last = (
55
+ db.query(ChatMessage)
56
+ .filter(ChatMessage.session_id == s.id)
57
+ .order_by(ChatMessage.created_at.desc())
58
+ .first()
59
+ )
60
+ count = db.query(ChatMessage).filter(ChatMessage.session_id == s.id).count()
61
+ result.append({
62
+ "id": s.id,
63
+ "title": s.title,
64
+ "created_at": s.created_at.isoformat() if s.created_at else None,
65
+ "updated_at": s.updated_at.isoformat() if s.updated_at else None,
66
+ "message_count": count,
67
+ "preview": (last.content[:80] + "…") if last and len(last.content) > 80 else (last.content if last else ""),
68
+ })
69
+ return result
70
+
71
+ def delete_session(self, db: Session, user_id: str, session_id: str) -> bool:
72
+ session = self.get_session(db, user_id, session_id)
73
+ if not session:
74
+ return False
75
+ db.delete(session)
76
+ db.commit()
77
+ return True
78
+
79
+ def ensure_session(self, db: Session, user_id: str, session_id: str | None) -> ChatSession:
80
+ if session_id:
81
+ session = self.get_session(db, user_id, session_id)
82
+ if session:
83
+ return session
84
+ return self.create_session(db, user_id)
85
+
86
+ def get_history(self, db: Session, session_id: str) -> list[dict]:
87
+ rows = (
88
+ db.query(ChatMessage)
89
+ .filter(ChatMessage.session_id == session_id)
90
+ .order_by(ChatMessage.created_at.desc())
91
+ .limit(CONTEXT_LIMIT)
92
+ .all()
93
+ )
94
+ return [{"role": r.role, "content": r.content} for r in reversed(rows)]
95
+
96
+ def list_messages(self, db: Session, session_id: str, limit: int = 100) -> list[dict]:
97
+ limit = min(max(limit, 1), MESSAGES_PER_SESSION_LIMIT)
98
+ rows = (
99
+ db.query(ChatMessage)
100
+ .filter(ChatMessage.session_id == session_id)
101
+ .order_by(ChatMessage.created_at.asc())
102
+ .limit(limit)
103
+ .all()
104
+ )
105
+ return [
106
+ {
107
+ "id": r.id,
108
+ "role": r.role,
109
+ "content": r.content,
110
+ "created_at": r.created_at.isoformat() if r.created_at else None,
111
+ }
112
+ for r in rows
113
+ ]
114
+
115
+ def add_message(
116
+ self, db: Session, session: ChatSession, user_id: str, role: str, content: str
117
+ ) -> None:
118
+ if not content or not content.strip():
119
+ return
120
+ text = content.strip()
121
+ db.add(
122
+ ChatMessage(
123
+ user_id=user_id,
124
+ session_id=session.id,
125
+ role=role,
126
+ content=text,
127
+ )
128
+ )
129
+ session.updated_at = datetime.now(timezone.utc)
130
+ if role == "user" and session.title in ("New chat", "Previous conversation"):
131
+ session.title = _title_from_prompt(text)
132
+ db.commit()
133
+ self._trim_old_messages(db, session.id)
134
+
135
+ def clear_session_messages(self, db: Session, user_id: str, session_id: str) -> bool:
136
+ session = self.get_session(db, user_id, session_id)
137
+ if not session:
138
+ return False
139
+ db.query(ChatMessage).filter(ChatMessage.session_id == session_id).delete()
140
+ session.title = "New chat"
141
+ session.updated_at = datetime.now(timezone.utc)
142
+ db.commit()
143
+ return True
144
+
145
+ def _trim_old_messages(self, db: Session, session_id: str) -> None:
146
+ total = db.query(ChatMessage).filter(ChatMessage.session_id == session_id).count()
147
+ if total <= MESSAGES_PER_SESSION_LIMIT:
148
+ return
149
+ excess = total - MESSAGES_PER_SESSION_LIMIT
150
+ oldest = (
151
+ db.query(ChatMessage)
152
+ .filter(ChatMessage.session_id == session_id)
153
+ .order_by(ChatMessage.created_at.asc())
154
+ .limit(excess)
155
+ .all()
156
+ )
157
+ ids = [m.id for m in oldest]
158
+ if ids:
159
+ db.query(ChatMessage).filter(ChatMessage.id.in_(ids)).delete(synchronize_session=False)
160
+ db.commit()
161
+
162
+ def _trim_old_sessions(self, db: Session, user_id: str) -> None:
163
+ total = db.query(ChatSession).filter(ChatSession.user_id == user_id).count()
164
+ if total <= SESSIONS_PER_USER_LIMIT:
165
+ return
166
+ excess = total - SESSIONS_PER_USER_LIMIT
167
+ oldest = (
168
+ db.query(ChatSession)
169
+ .filter(ChatSession.user_id == user_id)
170
+ .order_by(ChatSession.updated_at.asc())
171
+ .limit(excess)
172
+ .all()
173
+ )
174
+ for s in oldest:
175
+ db.delete(s)
176
+ db.commit()
177
+
178
 
179
  chat_memory = ChatMemoryManager()
180
 
 
329
  "Audit duplicate subscriptions and automate goal savings to enhance your trajectory."
330
  )
331
 
332
+ def get_chat_response(
333
+ db: Session, user_id: str, prompt: str, session_id: str | None = None
334
+ ) -> tuple[str, str]:
335
  """
336
+ Returns (assistant reply, session_id) grounded in database context.
337
  """
338
  from app.ai.ollama_integration import has_active_ai_backend
339
 
340
+ session = chat_memory.ensure_session(db, user_id, session_id)
341
+
342
  if not has_active_ai_backend():
343
  fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
344
+ chat_memory.add_message(db, session, user_id, "user", prompt)
345
+ chat_memory.add_message(db, session, user_id, "assistant", fallback_msg)
346
+ return fallback_msg, session.id
347
 
348
  # Build full message list: system prompt with real account data + history + new message
349
  sys_prompt = get_contextual_system_prompt(db, user_id)
350
+ history = chat_memory.get_history(db, session.id)
351
 
352
  full_messages = [{"role": "system", "content": sys_prompt}]
353
  for msg in history:
 
398
  if not response_content:
399
  response_content = get_offline_chat_fallback(db, user_id, prompt)
400
 
401
+ chat_memory.add_message(db, session, user_id, "user", prompt)
402
+ chat_memory.add_message(db, session, user_id, "assistant", response_content)
403
+ return response_content, session.id
404
 
405
+ def stream_chat_response(
406
+ db: Session, user_id: str, prompt: str, session_id: str | None = None
407
+ ):
408
  """
409
  Generates streaming chunks for WebSocket or HTTP SSE.
410
  """
411
  from app.ai.ollama_integration import has_active_ai_backend
412
 
413
+ session = chat_memory.ensure_session(db, user_id, session_id)
414
+
415
  if not has_active_ai_backend():
416
  fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
417
+ chat_memory.add_message(db, session, user_id, "user", prompt)
418
+ chat_memory.add_message(db, session, user_id, "assistant", fallback_msg)
419
  import time
420
  for word in fallback_msg.split(" "):
421
  yield word + " "
 
423
  return
424
 
425
  sys_prompt = get_contextual_system_prompt(db, user_id)
426
+ history = chat_memory.get_history(db, session.id)
427
 
428
  full_messages = [{"role": "system", "content": sys_prompt}]
429
  for msg in history:
430
  full_messages.append({"role": msg["role"], "content": msg["content"]})
431
  full_messages.append({"role": "user", "content": prompt})
432
 
433
+ chat_memory.add_message(db, session, user_id, "user", prompt)
434
 
435
  OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
436
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "")
 
453
  if content:
454
  complete_reply += content
455
  yield content
456
+ chat_memory.add_message(db, session, user_id, "assistant", complete_reply)
457
  return
458
  except Exception as e:
459
  print(f"OpenAI streaming error: {e}")
 
474
  if content:
475
  complete_reply += content
476
  yield content
477
+ chat_memory.add_message(db, session, user_id, "assistant", complete_reply)
478
  return
479
  except Exception as e:
480
  print(f"Groq streaming error: {e}")
 
485
  if chunk:
486
  complete_reply += chunk
487
  yield chunk
488
+ chat_memory.add_message(db, session, user_id, "assistant", complete_reply)
489
  except Exception as e:
490
  print(f"Ollama streaming error: {e}")
491
  fallback_msg = get_offline_chat_fallback(db, user_id, prompt)
492
  yield fallback_msg
493
+ chat_memory.add_message(db, session, user_id, "assistant", fallback_msg)
backend/app/ai/router.py CHANGED
@@ -14,7 +14,7 @@ from app.ai.behavior import analyze_spending_behavior
14
  from app.ai.coaching import calculate_financial_health_score, generate_daily_briefing
15
  from app.ai.subscriptions import analyze_subscriptions
16
  from app.ai.fraud import evaluate_transaction_for_fraud, get_user_fraud_alerts
17
- from app.ai.chat import get_chat_response
18
  from app.ai.intelligence import generate_weekly_coaching, explain_fraud_alert, generate_spending_narrative
19
 
20
  router = APIRouter(prefix="/api/ai", tags=["AI Intelligence"])
@@ -44,6 +44,10 @@ class SubscriptionSimulationRequest(BaseModel):
44
 
45
  class ChatMessageRequest(BaseModel):
46
  message: str
 
 
 
 
47
 
48
  # ─── FINANCIAL TWIN FORECASTS ──────────────────────────────────────────────────
49
 
@@ -175,11 +179,78 @@ def post_fraud_evaluate(transaction_id: str, db: Session = Depends(get_db)):
175
 
176
  # ─── CONTEXTUAL CHAT ENDPOINT ──────────────────────────────────────────────────
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  @router.post("/chat")
179
  def post_chat(req: ChatMessageRequest, user_id: Optional[str] = None, db: Session = Depends(get_db)):
180
  uid = get_user_id_fallback(db, user_id)
181
- response_msg = get_chat_response(db, uid, req.message)
182
- return {"response": response_msg}
183
 
184
 
185
  # ─── FINANCIAL COACH MODE ──────────────────────────────────────────────────────
 
14
  from app.ai.coaching import calculate_financial_health_score, generate_daily_briefing
15
  from app.ai.subscriptions import analyze_subscriptions
16
  from app.ai.fraud import evaluate_transaction_for_fraud, get_user_fraud_alerts
17
+ from app.ai.chat import get_chat_response, chat_memory
18
  from app.ai.intelligence import generate_weekly_coaching, explain_fraud_alert, generate_spending_narrative
19
 
20
  router = APIRouter(prefix="/api/ai", tags=["AI Intelligence"])
 
44
 
45
  class ChatMessageRequest(BaseModel):
46
  message: str
47
+ session_id: Optional[str] = None
48
+
49
+ class ChatSessionCreateRequest(BaseModel):
50
+ title: Optional[str] = "New chat"
51
 
52
  # ─── FINANCIAL TWIN FORECASTS ──────────────────────────────────────────────────
53
 
 
179
 
180
  # ─── CONTEXTUAL CHAT ENDPOINT ──────────────────────────────────────────────────
181
 
182
+ @router.get("/chat/sessions")
183
+ def list_chat_sessions(
184
+ user_id: Optional[str] = None,
185
+ limit: int = Query(30, ge=1, le=50),
186
+ db: Session = Depends(get_db),
187
+ ):
188
+ uid = get_user_id_fallback(db, user_id)
189
+ sessions = chat_memory.list_sessions(db, uid, limit=limit)
190
+ return {"sessions": sessions, "count": len(sessions)}
191
+
192
+
193
+ @router.post("/chat/sessions")
194
+ def create_chat_session(
195
+ body: ChatSessionCreateRequest,
196
+ user_id: Optional[str] = None,
197
+ db: Session = Depends(get_db),
198
+ ):
199
+ uid = get_user_id_fallback(db, user_id)
200
+ session = chat_memory.create_session(db, uid, title=body.title or "New chat")
201
+ return {
202
+ "id": session.id,
203
+ "title": session.title,
204
+ "created_at": session.created_at.isoformat() if session.created_at else None,
205
+ "updated_at": session.updated_at.isoformat() if session.updated_at else None,
206
+ "message_count": 0,
207
+ "preview": "",
208
+ }
209
+
210
+
211
+ @router.delete("/chat/sessions/{session_id}")
212
+ def delete_chat_session(
213
+ session_id: str,
214
+ user_id: Optional[str] = None,
215
+ db: Session = Depends(get_db),
216
+ ):
217
+ uid = get_user_id_fallback(db, user_id)
218
+ if not chat_memory.delete_session(db, uid, session_id):
219
+ raise HTTPException(status_code=404, detail="Chat session not found")
220
+ return {"ok": True, "message": "Chat deleted"}
221
+
222
+
223
+ @router.get("/chat/history")
224
+ def get_chat_history(
225
+ session_id: str = Query(..., description="Chat session ID"),
226
+ user_id: Optional[str] = None,
227
+ limit: int = Query(100, ge=1, le=200),
228
+ db: Session = Depends(get_db),
229
+ ):
230
+ uid = get_user_id_fallback(db, user_id)
231
+ if not chat_memory.get_session(db, uid, session_id):
232
+ raise HTTPException(status_code=404, detail="Chat session not found")
233
+ messages = chat_memory.list_messages(db, session_id, limit=limit)
234
+ return {"session_id": session_id, "messages": messages, "count": len(messages)}
235
+
236
+
237
+ @router.delete("/chat/history")
238
+ def delete_chat_history(
239
+ session_id: str = Query(..., description="Chat session ID"),
240
+ user_id: Optional[str] = None,
241
+ db: Session = Depends(get_db),
242
+ ):
243
+ uid = get_user_id_fallback(db, user_id)
244
+ if not chat_memory.clear_session_messages(db, uid, session_id):
245
+ raise HTTPException(status_code=404, detail="Chat session not found")
246
+ return {"ok": True, "message": "Conversation cleared", "session_id": session_id}
247
+
248
+
249
  @router.post("/chat")
250
  def post_chat(req: ChatMessageRequest, user_id: Optional[str] = None, db: Session = Depends(get_db)):
251
  uid = get_user_id_fallback(db, user_id)
252
+ response_msg, session_id = get_chat_response(db, uid, req.message, req.session_id)
253
+ return {"response": response_msg, "session_id": session_id}
254
 
255
 
256
  # ─── FINANCIAL COACH MODE ──────────────────────────────────────────────────────
backend/app/database/chat_migrate.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight schema updates for chat sessions (SQLite/HF compatible)."""
2
+ from sqlalchemy import inspect, text
3
+
4
+ from app.database.database import engine
5
+ from app.database.models import ChatSession, ChatMessage
6
+
7
+
8
+ def migrate_chat_schema() -> None:
9
+ insp = inspect(engine)
10
+ tables = insp.get_table_names()
11
+
12
+ if "chat_sessions" not in tables:
13
+ ChatSession.__table__.create(bind=engine, checkfirst=True)
14
+
15
+ if "chat_messages" not in tables:
16
+ ChatMessage.__table__.create(bind=engine, checkfirst=True)
17
+ return
18
+
19
+ cols = {c["name"] for c in insp.get_columns("chat_messages")}
20
+ if "session_id" not in cols:
21
+ with engine.begin() as conn:
22
+ conn.execute(text("ALTER TABLE chat_messages ADD COLUMN session_id VARCHAR"))
23
+
24
+ _attach_orphan_messages()
25
+
26
+
27
+ def _attach_orphan_messages() -> None:
28
+ from sqlalchemy.orm import Session
29
+ from app.database.database import SessionLocal
30
+
31
+ db = SessionLocal()
32
+ try:
33
+ orphans = (
34
+ db.query(ChatMessage)
35
+ .filter(ChatMessage.session_id.is_(None))
36
+ .all()
37
+ )
38
+ if not orphans:
39
+ return
40
+ by_user: dict[str, list] = {}
41
+ for msg in orphans:
42
+ by_user.setdefault(msg.user_id, []).append(msg)
43
+ for user_id, msgs in by_user.items():
44
+ session = ChatSession(user_id=user_id, title="Previous conversation")
45
+ db.add(session)
46
+ db.flush()
47
+ for m in msgs:
48
+ m.session_id = session.id
49
+ session.updated_at = msgs[-1].created_at if msgs[-1].created_at else session.updated_at
50
+ db.commit()
51
+ finally:
52
+ db.close()
backend/app/database/models.py CHANGED
@@ -27,6 +27,31 @@ class User(Base):
27
  notifications = relationship("Notification", back_populates="user")
28
  analytics_snapshots = relationship("AnalyticsSnapshot", back_populates="user")
29
  payments = relationship("Payment", back_populates="user")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  class Account(Base):
32
  __tablename__ = "accounts"
 
27
  notifications = relationship("Notification", back_populates="user")
28
  analytics_snapshots = relationship("AnalyticsSnapshot", back_populates="user")
29
  payments = relationship("Payment", back_populates="user")
30
+ chat_sessions = relationship("ChatSession", back_populates="user", cascade="all, delete-orphan")
31
+
32
+ class ChatSession(Base):
33
+ __tablename__ = "chat_sessions"
34
+
35
+ id = Column(String, primary_key=True, index=True, default=generate_uuid)
36
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
37
+ title = Column(String, default="New chat", nullable=False)
38
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
39
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
40
+
41
+ user = relationship("User", back_populates="chat_sessions")
42
+ messages = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan")
43
+
44
+ class ChatMessage(Base):
45
+ __tablename__ = "chat_messages"
46
+
47
+ id = Column(String, primary_key=True, index=True, default=generate_uuid)
48
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
49
+ session_id = Column(String, ForeignKey("chat_sessions.id"), nullable=True, index=True)
50
+ role = Column(String, nullable=False) # user | assistant
51
+ content = Column(Text, nullable=False)
52
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
53
+
54
+ session = relationship("ChatSession", back_populates="messages")
55
 
56
  class Account(Base):
57
  __tablename__ = "accounts"
backend/app/main.py CHANGED
@@ -109,6 +109,8 @@ async def rate_limit(request: Request, call_next):
109
  def startup():
110
  api_logger.info("BankBot API starting", extra={"version": "2.0.0"})
111
  Base.metadata.create_all(bind=engine)
 
 
112
  api_logger.info("Database tables ready")
113
 
114
  # Log active backends
 
109
  def startup():
110
  api_logger.info("BankBot API starting", extra={"version": "2.0.0"})
111
  Base.metadata.create_all(bind=engine)
112
+ from app.database.chat_migrate import migrate_chat_schema
113
+ migrate_chat_schema()
114
  api_logger.info("Database tables ready")
115
 
116
  # Log active backends
backend/app/websocket/router.py CHANGED
@@ -114,7 +114,8 @@ async def websocket_chat_endpoint(
114
  await websocket.send_json({"type": "chat_start"})
115
 
116
  try:
117
- for chunk in stream_chat_response(db, user_id, prompt):
 
118
  if chunk:
119
  await websocket.send_json({"type": "chat_chunk", "content": chunk})
120
  except Exception as e:
 
114
  await websocket.send_json({"type": "chat_start"})
115
 
116
  try:
117
+ ws_session_id = payload.get("session_id")
118
+ for chunk in stream_chat_response(db, user_id, prompt, ws_session_id):
119
  if chunk:
120
  await websocket.send_json({"type": "chat_chunk", "content": chunk})
121
  except Exception as e:
frontend/src/app/chat/page.tsx CHANGED
@@ -5,11 +5,11 @@ import { motion, AnimatePresence } from "framer-motion";
5
  import {
6
  Send, Sparkles, TrendingUp, Shield, PieChart,
7
  Zap, Copy, ThumbsUp, ThumbsDown, RotateCcw, Mic, Paperclip,
 
8
  } from "lucide-react";
9
  import { aiApi } from "@/lib/api";
10
  import { useAuthStore } from "@/lib/stores/authStore";
11
 
12
- // ─── Types ────────────────────────────────────────────────────────────────────
13
  interface Message {
14
  id: string;
15
  role: "user" | "assistant";
@@ -18,15 +18,34 @@ interface Message {
18
  timestamp: Date;
19
  }
20
 
21
- // ─── Suggested Prompts ────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
22
  const suggestions = [
23
- { icon: TrendingUp, label: "What's my total balance?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
24
- { icon: PieChart, label: "Analyze my spending this month", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
25
- { icon: Shield, label: "Explain my fraud alerts", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
26
- { icon: Zap, label: "Give me a savings nudge", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
27
  ];
28
 
29
- // ─── AI Orb ───────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
30
  function AIOrb({ isThinking }: { isThinking: boolean }) {
31
  return (
32
  <div className="relative flex items-center justify-center">
@@ -49,10 +68,7 @@ function AIOrb({ isThinking }: { isThinking: boolean }) {
49
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
50
  className="relative h-16 w-16 rounded-full bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 flex items-center justify-center"
51
  >
52
- <motion.div
53
- animate={{ rotate: isThinking ? 360 : 0 }}
54
- transition={{ duration: 2, repeat: isThinking ? Infinity : 0, ease: "linear" }}
55
- >
56
  <Sparkles className="h-7 w-7 text-white" />
57
  </motion.div>
58
  </motion.div>
@@ -60,7 +76,6 @@ function AIOrb({ isThinking }: { isThinking: boolean }) {
60
  );
61
  }
62
 
63
- // ─── Message Bubble ───────────────────────────────────────────────────────────
64
  function MessageBubble({
65
  message,
66
  onCopy,
@@ -90,18 +105,13 @@ function MessageBubble({
90
  transition={{ duration: 0.3, ease: "easeOut" }}
91
  className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
92
  >
93
- {/* Avatar */}
94
  <div
95
  className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${
96
- isUser
97
- ? "bg-gradient-to-br from-blue-500 to-purple-600 text-white"
98
- : "bg-gradient-to-br from-emerald-400 to-cyan-500"
99
  }`}
100
  >
101
  {isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
102
  </div>
103
-
104
- {/* Bubble */}
105
  <div className={`max-w-[75%] flex flex-col gap-1 ${isUser ? "items-end" : "items-start"}`}>
106
  <div
107
  className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${
@@ -113,22 +123,15 @@ function MessageBubble({
113
  <div className="whitespace-pre-wrap">{renderContent(message.content)}</div>
114
  {message.streaming && <span className="streaming-cursor" />}
115
  </div>
116
-
117
- {/* Action buttons (AI messages only) */}
118
  {!isUser && !message.streaming && (
119
  <div className="flex items-center gap-1 px-1">
120
  {[
121
- { icon: Copy, label: "Copy", action: () => onCopy(message.content) },
122
- { icon: ThumbsUp, label: "Good", action: () => {} },
123
- { icon: ThumbsDown,label: "Bad", action: () => {} },
124
  { icon: RotateCcw, label: "Retry", action: () => {} },
125
  ].map(({ icon: Icon, label, action }) => (
126
- <button
127
- key={label}
128
- title={label}
129
- onClick={action}
130
- className="p-1 rounded-lg text-zinc-600 hover:text-zinc-400 hover:bg-white/5 transition-all"
131
- >
132
  <Icon className="h-3 w-3" />
133
  </button>
134
  ))}
@@ -139,77 +142,149 @@ function MessageBubble({
139
  );
140
  }
141
 
142
- // ─── Main Chat Page ───────────────────────────────────────────────────────────
143
  export default function ChatPage() {
144
  const { user } = useAuthStore();
145
  const userInitial = user?.name?.charAt(0).toUpperCase() ?? "U";
146
 
147
- const [messages, setMessages] = useState<Message[]>([
148
- {
149
- id: "welcome",
150
- role: "assistant",
151
- content:
152
- "Hello! I'm your AI financial assistant with full context of your accounts, spending patterns, and goals.\n\nWhat would you like to explore today?",
153
- timestamp: new Date(),
154
- },
155
- ]);
156
  const [input, setInput] = useState("");
157
  const [isThinking, setIsThinking] = useState(false);
 
 
158
  const messagesEndRef = useRef<HTMLDivElement>(null);
159
  const inputRef = useRef<HTMLTextAreaElement>(null);
160
- const abortRef = useRef<AbortController | null>(null);
161
 
162
- // Auto-scroll on new messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  useEffect(() => {
164
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
165
  }, [messages]);
166
 
167
- // ── Simulated word-by-word streaming from HTTP response ───────────────────
168
  const streamWords = useCallback((msgId: string, fullText: string) => {
169
  const words = fullText.split(" ");
170
  let i = 0;
171
-
172
- // Mark as streaming
173
- setMessages((prev) =>
174
- prev.map((m) => (m.id === msgId ? { ...m, streaming: true } : m))
175
- );
176
-
177
  const interval = setInterval(() => {
178
  if (i >= words.length) {
179
  clearInterval(interval);
180
- setMessages((prev) =>
181
- prev.map((m) => (m.id === msgId ? { ...m, streaming: false } : m))
182
- );
183
  return;
184
  }
185
  const chunk = words.slice(0, i + 1).join(" ");
186
- setMessages((prev) =>
187
- prev.map((m) => (m.id === msgId ? { ...m, content: chunk } : m))
188
- );
189
  i++;
190
- }, 28); // ~35 words/sec — feels natural
191
  }, []);
192
 
193
- // ── Send message ──────────────────────────────────────────────────────────
194
  const sendMessage = useCallback(
195
  async (text?: string) => {
196
  const content = (text ?? input).trim();
197
  if (!content || isThinking) return;
198
 
199
- // Cancel any in-flight request
200
- abortRef.current?.abort();
201
- abortRef.current = new AbortController();
 
 
 
 
202
 
203
  setInput("");
204
  setIsThinking(true);
205
 
206
- // Add user message
207
  setMessages((prev) => [
208
- ...prev,
209
  { id: `user-${Date.now()}`, role: "user", content, timestamp: new Date() },
210
  ]);
211
 
212
- // Placeholder AI message
213
  const aiId = `ai-${Date.now()}`;
214
  setMessages((prev) => [
215
  ...prev,
@@ -217,9 +292,16 @@ export default function ChatPage() {
217
  ]);
218
 
219
  try {
220
- const res = await aiApi.chat(content, user?.user_id);
 
 
 
 
 
 
221
  setIsThinking(false);
222
  streamWords(aiId, res.response);
 
223
  } catch (err) {
224
  setIsThinking(false);
225
  const errMsg =
@@ -227,182 +309,265 @@ export default function ChatPage() {
227
  ? "Session expired. Please refresh and log in again."
228
  : "⚠️ Could not reach the AI backend. Please try again.";
229
  setMessages((prev) =>
230
- prev.map((m) =>
231
- m.id === aiId
232
- ? { ...m, content: errMsg, streaming: false }
233
- : m
234
- )
235
  );
236
  }
237
  },
238
- [input, isThinking, user?.user_id, streamWords]
239
  );
240
 
241
- const handleKeyDown = (e: React.KeyboardEvent) => {
242
- if (e.key === "Enter" && !e.shiftKey) {
243
- e.preventDefault();
244
- sendMessage();
 
 
 
 
 
 
 
 
 
245
  }
246
  };
247
 
248
- const copyToClipboard = (text: string) => {
249
- navigator.clipboard.writeText(text).catch(() => {});
 
 
 
 
 
 
 
250
  };
251
 
252
  const isStreaming = messages.some((m) => m.streaming);
 
253
 
254
  return (
255
- <div className="flex h-[calc(100vh-4rem)] flex-col -m-8">
256
- {/* ── Header ─────────────────────────────────────────────────────────── */}
257
- <div className="flex items-center justify-between border-b border-white/10 bg-black/20 backdrop-blur-xl px-8 py-4 flex-shrink-0">
258
- <div className="flex items-center gap-3">
259
- {/* Live indicator */}
260
- <div className="flex items-center gap-2">
261
- <div className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
262
- <span className="text-xs text-emerald-400">Live AI</span>
263
- </div>
264
- <div className="h-4 w-px bg-white/10" />
265
- <div>
266
- <h1 className="text-base font-semibold text-white">BankBot AI Assistant</h1>
267
- <p className="text-xs text-zinc-500">Context-aware · Groq-powered · Personalized</p>
268
- </div>
269
  </div>
270
- <div className="flex items-center gap-2 text-xs text-zinc-500">
271
- <Shield className="h-3.5 w-3.5 text-emerald-400" />
272
- <span>Encrypted</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  </div>
274
- </div>
275
 
276
- {/* ── Messages ───────────────────────────────────────────────────────── */}
277
- <div className="flex-1 overflow-y-auto px-8 py-6 space-y-5">
278
- {/* Welcome orb (only when just the welcome message exists) */}
279
- {messages.length === 1 && (
280
- <motion.div
281
- initial={{ opacity: 0, scale: 0.8 }}
282
- animate={{ opacity: 1, scale: 1 }}
283
- transition={{ duration: 0.6, ease: "easeOut" }}
284
- className="flex flex-col items-center gap-6 py-8"
285
- >
286
- <AIOrb isThinking={false} />
287
- <div className="text-center">
288
- <p className="text-lg font-semibold text-white">Your AI Financial Twin</p>
289
- <p className="text-sm text-zinc-400 mt-1">Ask me anything about your finances</p>
290
  </div>
291
- <div className="grid grid-cols-2 gap-3 w-full max-w-md">
292
- {suggestions.map((s) => {
293
- const Icon = s.icon;
294
- return (
295
- <motion.button
296
- key={s.label}
297
- whileHover={{ scale: 1.03 }}
298
- whileTap={{ scale: 0.97 }}
299
- onClick={() => sendMessage(s.label)}
300
- className={`flex items-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium transition-all ${s.bg} ${s.color} hover:brightness-110`}
301
- >
302
- <Icon className="h-4 w-4" />
303
- {s.label}
304
- </motion.button>
305
- );
306
- })}
307
  </div>
308
- </motion.div>
309
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
- {messages.map((msg) => (
312
- <MessageBubble
313
- key={msg.id}
314
- message={msg}
315
- onCopy={copyToClipboard}
316
- userInitial={userInitial}
317
- />
318
- ))}
319
-
320
- {/* Thinking dots */}
321
- <AnimatePresence>
322
- {isThinking && (
323
  <motion.div
324
- initial={{ opacity: 0, y: 10 }}
325
- animate={{ opacity: 1, y: 0 }}
326
- exit={{ opacity: 0, y: -10 }}
327
- className="flex gap-3"
328
  >
329
- <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-cyan-500">
330
- <Sparkles className="h-4 w-4 text-white" />
 
 
331
  </div>
332
- <div className="glass rounded-2xl rounded-tl-sm px-4 py-3 border border-white/10">
333
- <div className="flex gap-1 items-center h-4">
334
- {[0, 1, 2].map((i) => (
335
- <motion.div
336
- key={i}
337
- animate={{ y: [0, -4, 0] }}
338
- transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
339
- className="h-1.5 w-1.5 rounded-full bg-emerald-400"
340
- />
341
- ))}
342
- </div>
 
 
 
 
 
343
  </div>
344
  </motion.div>
345
  )}
346
- </AnimatePresence>
347
 
348
- <div ref={messagesEndRef} />
349
- </div>
 
 
350
 
351
- {/* ── Quick suggestions (after first message) ────────────────────────── */}
352
- {messages.length > 1 && (
353
- <div className="flex gap-2 px-8 pb-2 overflow-x-auto flex-shrink-0">
354
- {suggestions.map((s) => {
355
- const Icon = s.icon;
356
- return (
357
- <button
358
- key={s.label}
359
- onClick={() => sendMessage(s.label)}
360
- disabled={isStreaming || isThinking}
361
- className={`flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all disabled:opacity-40 ${s.bg} ${s.color} hover:brightness-110`}
362
- >
363
- <Icon className="h-3 w-3" />
364
- {s.label}
365
- </button>
366
- );
367
- })}
 
 
 
 
 
368
  </div>
369
- )}
370
 
371
- {/* ── Input ──────────────────────────────────────────────────────────── */}
372
- <div className="flex-shrink-0 border-t border-white/10 bg-black/20 backdrop-blur-xl px-8 py-4">
373
- <div className="flex items-end gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 focus-within:border-emerald-500/40 transition-all">
374
- <button className="text-zinc-500 hover:text-zinc-300 transition-colors mb-0.5">
375
- <Paperclip className="h-4 w-4" />
376
- </button>
377
- <textarea
378
- ref={inputRef}
379
- value={input}
380
- onChange={(e) => setInput(e.target.value)}
381
- onKeyDown={handleKeyDown}
382
- placeholder="Ask about your finances, forecasts, fraud alerts..."
383
- rows={1}
384
- disabled={isStreaming || isThinking}
385
- className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-zinc-500 focus:outline-none leading-relaxed max-h-32 disabled:opacity-50"
386
- style={{ minHeight: "24px" }}
387
- />
388
- <div className="flex items-center gap-2 mb-0.5">
389
- <button className="text-zinc-500 hover:text-zinc-300 transition-colors">
390
- <Mic className="h-4 w-4" />
 
 
 
391
  </button>
392
- <motion.button
393
- whileHover={{ scale: 1.05 }}
394
- whileTap={{ scale: 0.95 }}
395
- onClick={() => sendMessage()}
396
- disabled={!input.trim() || isThinking || isStreaming}
397
- className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
398
- >
399
- <Send className="h-3.5 w-3.5" />
400
- </motion.button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  </div>
 
 
 
402
  </div>
403
- <p className="text-center text-xs text-zinc-600 mt-2">
404
- AI responses are for informational purposes only. Not financial advice.
405
- </p>
406
  </div>
407
  </div>
408
  );
 
5
  import {
6
  Send, Sparkles, TrendingUp, Shield, PieChart,
7
  Zap, Copy, ThumbsUp, ThumbsDown, RotateCcw, Mic, Paperclip,
8
+ Plus, MessageSquare, Trash2,
9
  } from "lucide-react";
10
  import { aiApi } from "@/lib/api";
11
  import { useAuthStore } from "@/lib/stores/authStore";
12
 
 
13
  interface Message {
14
  id: string;
15
  role: "user" | "assistant";
 
18
  timestamp: Date;
19
  }
20
 
21
+ interface ChatSessionItem {
22
+ id: string;
23
+ title: string;
24
+ created_at: string | null;
25
+ updated_at: string | null;
26
+ message_count: number;
27
+ preview: string;
28
+ }
29
+
30
  const suggestions = [
31
+ { icon: TrendingUp, label: "What's my total balance?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
32
+ { icon: PieChart, label: "Analyze my spending this month", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
33
+ { icon: Shield, label: "Explain my fraud alerts", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
34
+ { icon: Zap, label: "Give me a savings nudge", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
35
  ];
36
 
37
+ const WELCOME_MESSAGE: Message = {
38
+ id: "welcome",
39
+ role: "assistant",
40
+ content:
41
+ "Hello! I'm your AI financial assistant with full context of your accounts, spending patterns, and goals.\n\nWhat would you like to explore today?",
42
+ timestamp: new Date(),
43
+ };
44
+
45
+ function sessionStorageKey(userId?: string) {
46
+ return `bb_active_chat_session_${userId ?? "default"}`;
47
+ }
48
+
49
  function AIOrb({ isThinking }: { isThinking: boolean }) {
50
  return (
51
  <div className="relative flex items-center justify-center">
 
68
  transition={{ duration: isThinking ? 0.8 : 3, repeat: Infinity, ease: "easeInOut" }}
69
  className="relative h-16 w-16 rounded-full bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 flex items-center justify-center"
70
  >
71
+ <motion.div animate={{ rotate: isThinking ? 360 : 0 }} transition={{ duration: 2, repeat: isThinking ? Infinity : 0, ease: "linear" }}>
 
 
 
72
  <Sparkles className="h-7 w-7 text-white" />
73
  </motion.div>
74
  </motion.div>
 
76
  );
77
  }
78
 
 
79
  function MessageBubble({
80
  message,
81
  onCopy,
 
105
  transition={{ duration: 0.3, ease: "easeOut" }}
106
  className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
107
  >
 
108
  <div
109
  className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl text-xs font-bold ${
110
+ isUser ? "bg-gradient-to-br from-blue-500 to-purple-600 text-white" : "bg-gradient-to-br from-emerald-400 to-cyan-500"
 
 
111
  }`}
112
  >
113
  {isUser ? userInitial : <Sparkles className="h-4 w-4 text-white" />}
114
  </div>
 
 
115
  <div className={`max-w-[75%] flex flex-col gap-1 ${isUser ? "items-end" : "items-start"}`}>
116
  <div
117
  className={`rounded-2xl px-4 py-3 text-sm leading-relaxed ${
 
123
  <div className="whitespace-pre-wrap">{renderContent(message.content)}</div>
124
  {message.streaming && <span className="streaming-cursor" />}
125
  </div>
 
 
126
  {!isUser && !message.streaming && (
127
  <div className="flex items-center gap-1 px-1">
128
  {[
129
+ { icon: Copy, label: "Copy", action: () => onCopy(message.content) },
130
+ { icon: ThumbsUp, label: "Good", action: () => {} },
131
+ { icon: ThumbsDown, label: "Bad", action: () => {} },
132
  { icon: RotateCcw, label: "Retry", action: () => {} },
133
  ].map(({ icon: Icon, label, action }) => (
134
+ <button key={label} title={label} onClick={action} className="p-1 rounded-lg text-zinc-600 hover:text-zinc-400 hover:bg-white/5 transition-all">
 
 
 
 
 
135
  <Icon className="h-3 w-3" />
136
  </button>
137
  ))}
 
142
  );
143
  }
144
 
 
145
  export default function ChatPage() {
146
  const { user } = useAuthStore();
147
  const userInitial = user?.name?.charAt(0).toUpperCase() ?? "U";
148
 
149
+ const [sessions, setSessions] = useState<ChatSessionItem[]>([]);
150
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
151
+ const [messages, setMessages] = useState<Message[]>([WELCOME_MESSAGE]);
 
 
 
 
 
 
152
  const [input, setInput] = useState("");
153
  const [isThinking, setIsThinking] = useState(false);
154
+ const [sessionsLoading, setSessionsLoading] = useState(true);
155
+ const [historyLoading, setHistoryLoading] = useState(false);
156
  const messagesEndRef = useRef<HTMLDivElement>(null);
157
  const inputRef = useRef<HTMLTextAreaElement>(null);
 
158
 
159
+ const refreshSessions = useCallback(async () => {
160
+ const res = await aiApi.chatSessions(user?.user_id);
161
+ setSessions(res.sessions);
162
+ return res.sessions;
163
+ }, [user?.user_id]);
164
+
165
+ const loadSessionMessages = useCallback(async (sessionId: string) => {
166
+ setHistoryLoading(true);
167
+ try {
168
+ const res = await aiApi.chatHistory(sessionId, user?.user_id);
169
+ if (res.messages.length > 0) {
170
+ setMessages(
171
+ res.messages.map((m) => ({
172
+ id: m.id,
173
+ role: m.role,
174
+ content: m.content,
175
+ timestamp: m.created_at ? new Date(m.created_at) : new Date(),
176
+ }))
177
+ );
178
+ } else {
179
+ setMessages([WELCOME_MESSAGE]);
180
+ }
181
+ } catch {
182
+ setMessages([WELCOME_MESSAGE]);
183
+ } finally {
184
+ setHistoryLoading(false);
185
+ }
186
+ }, [user?.user_id]);
187
+
188
+ const selectSession = useCallback(
189
+ async (sessionId: string) => {
190
+ setActiveSessionId(sessionId);
191
+ if (typeof window !== "undefined") {
192
+ localStorage.setItem(sessionStorageKey(user?.user_id), sessionId);
193
+ }
194
+ await loadSessionMessages(sessionId);
195
+ },
196
+ [loadSessionMessages, user?.user_id]
197
+ );
198
+
199
+ const startNewChat = useCallback(async () => {
200
+ const created = await aiApi.createChatSession(user?.user_id);
201
+ setSessions((prev) => [created, ...prev.filter((s) => s.id !== created.id)]);
202
+ setActiveSessionId(created.id);
203
+ if (typeof window !== "undefined") {
204
+ localStorage.setItem(sessionStorageKey(user?.user_id), created.id);
205
+ }
206
+ setMessages([WELCOME_MESSAGE]);
207
+ }, [user?.user_id]);
208
+
209
+ // Bootstrap sessions on mount
210
+ useEffect(() => {
211
+ let cancelled = false;
212
+ (async () => {
213
+ setSessionsLoading(true);
214
+ try {
215
+ let list = await refreshSessions();
216
+ if (cancelled) return;
217
+
218
+ const savedId = typeof window !== "undefined"
219
+ ? localStorage.getItem(sessionStorageKey(user?.user_id))
220
+ : null;
221
+
222
+ if (savedId && list.some((s) => s.id === savedId)) {
223
+ await selectSession(savedId);
224
+ } else if (list.length > 0) {
225
+ await selectSession(list[0].id);
226
+ } else {
227
+ await startNewChat();
228
+ }
229
+ } catch {
230
+ if (!cancelled) {
231
+ setMessages([WELCOME_MESSAGE]);
232
+ try {
233
+ await startNewChat();
234
+ } catch {
235
+ /* offline */
236
+ }
237
+ }
238
+ } finally {
239
+ if (!cancelled) setSessionsLoading(false);
240
+ }
241
+ })();
242
+ return () => {
243
+ cancelled = true;
244
+ };
245
+ }, [user?.user_id]); // eslint-disable-line react-hooks/exhaustive-deps
246
+
247
  useEffect(() => {
248
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
249
  }, [messages]);
250
 
 
251
  const streamWords = useCallback((msgId: string, fullText: string) => {
252
  const words = fullText.split(" ");
253
  let i = 0;
254
+ setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, streaming: true } : m)));
 
 
 
 
 
255
  const interval = setInterval(() => {
256
  if (i >= words.length) {
257
  clearInterval(interval);
258
+ setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, streaming: false } : m)));
 
 
259
  return;
260
  }
261
  const chunk = words.slice(0, i + 1).join(" ");
262
+ setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, content: chunk } : m)));
 
 
263
  i++;
264
+ }, 28);
265
  }, []);
266
 
 
267
  const sendMessage = useCallback(
268
  async (text?: string) => {
269
  const content = (text ?? input).trim();
270
  if (!content || isThinking) return;
271
 
272
+ let sessionId = activeSessionId;
273
+ if (!sessionId) {
274
+ const created = await aiApi.createChatSession(user?.user_id);
275
+ sessionId = created.id;
276
+ setActiveSessionId(created.id);
277
+ setSessions((prev) => [created, ...prev]);
278
+ }
279
 
280
  setInput("");
281
  setIsThinking(true);
282
 
 
283
  setMessages((prev) => [
284
+ ...prev.filter((m) => m.id !== "welcome"),
285
  { id: `user-${Date.now()}`, role: "user", content, timestamp: new Date() },
286
  ]);
287
 
 
288
  const aiId = `ai-${Date.now()}`;
289
  setMessages((prev) => [
290
  ...prev,
 
292
  ]);
293
 
294
  try {
295
+ const res = await aiApi.chat(content, user?.user_id, sessionId ?? undefined);
296
+ if (res.session_id && res.session_id !== activeSessionId) {
297
+ setActiveSessionId(res.session_id);
298
+ if (typeof window !== "undefined") {
299
+ localStorage.setItem(sessionStorageKey(user?.user_id), res.session_id);
300
+ }
301
+ }
302
  setIsThinking(false);
303
  streamWords(aiId, res.response);
304
+ await refreshSessions();
305
  } catch (err) {
306
  setIsThinking(false);
307
  const errMsg =
 
309
  ? "Session expired. Please refresh and log in again."
310
  : "⚠️ Could not reach the AI backend. Please try again.";
311
  setMessages((prev) =>
312
+ prev.map((m) => (m.id === aiId ? { ...m, content: errMsg, streaming: false } : m))
 
 
 
 
313
  );
314
  }
315
  },
316
+ [input, isThinking, activeSessionId, user?.user_id, streamWords, refreshSessions]
317
  );
318
 
319
+ const deleteSession = async (sessionId: string, e: React.MouseEvent) => {
320
+ e.stopPropagation();
321
+ if (!confirm("Delete this conversation?")) return;
322
+ try {
323
+ await aiApi.deleteChatSession(sessionId, user?.user_id);
324
+ const remaining = sessions.filter((s) => s.id !== sessionId);
325
+ setSessions(remaining);
326
+ if (activeSessionId === sessionId) {
327
+ if (remaining.length > 0) await selectSession(remaining[0].id);
328
+ else await startNewChat();
329
+ }
330
+ } catch {
331
+ /* ignore */
332
  }
333
  };
334
 
335
+ const clearCurrentChat = async () => {
336
+ if (!activeSessionId || isThinking || isStreaming) return;
337
+ try {
338
+ await aiApi.clearChatHistory(activeSessionId, user?.user_id);
339
+ setMessages([WELCOME_MESSAGE]);
340
+ await refreshSessions();
341
+ } catch {
342
+ setMessages([WELCOME_MESSAGE]);
343
+ }
344
  };
345
 
346
  const isStreaming = messages.some((m) => m.streaming);
347
+ const showWelcome = !historyLoading && messages.length === 1 && messages[0].id === "welcome";
348
 
349
  return (
350
+ <div className="flex h-[calc(100vh-4rem)] -m-8">
351
+ {/* ── Chat history sidebar ─────────────────────────────────────────── */}
352
+ <aside className="hidden md:flex w-64 lg:w-72 flex-shrink-0 flex-col border-r border-white/10 bg-black/30 backdrop-blur-xl">
353
+ <div className="p-4 border-b border-white/10">
354
+ <button
355
+ type="button"
356
+ onClick={startNewChat}
357
+ disabled={sessionsLoading || isThinking || isStreaming}
358
+ className="flex w-full items-center justify-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-40"
359
+ >
360
+ <Plus className="h-4 w-4" />
361
+ New chat
362
+ </button>
 
363
  </div>
364
+ <div className="flex-1 overflow-y-auto p-2 space-y-1">
365
+ <p className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">Chat history</p>
366
+ {sessionsLoading ? (
367
+ <p className="px-3 py-4 text-xs text-zinc-500">Loading chats...</p>
368
+ ) : sessions.length === 0 ? (
369
+ <p className="px-3 py-4 text-xs text-zinc-500">No conversations yet</p>
370
+ ) : (
371
+ sessions.map((s) => (
372
+ <div
373
+ key={s.id}
374
+ className={`group w-full flex items-start gap-1 rounded-xl transition-all ${
375
+ activeSessionId === s.id
376
+ ? "bg-white/10 border border-white/15"
377
+ : "hover:bg-white/5 border border-transparent"
378
+ }`}
379
+ >
380
+ <button
381
+ type="button"
382
+ onClick={() => selectSession(s.id)}
383
+ className="flex flex-1 items-start gap-2 px-3 py-2.5 text-left min-w-0"
384
+ >
385
+ <MessageSquare className={`h-4 w-4 flex-shrink-0 mt-0.5 ${activeSessionId === s.id ? "text-emerald-400" : "text-zinc-500"}`} />
386
+ <div className="flex-1 min-w-0">
387
+ <p className={`text-sm font-medium truncate ${activeSessionId === s.id ? "text-white" : "text-zinc-300"}`}>
388
+ {s.title}
389
+ </p>
390
+ {s.preview && (
391
+ <p className="text-[11px] text-zinc-500 truncate mt-0.5">{s.preview}</p>
392
+ )}
393
+ </div>
394
+ </button>
395
+ <button
396
+ type="button"
397
+ onClick={(e) => deleteSession(s.id, e)}
398
+ className="opacity-0 group-hover:opacity-100 mr-1 mt-2 p-1 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-all flex-shrink-0"
399
+ title="Delete chat"
400
+ >
401
+ <Trash2 className="h-3.5 w-3.5" />
402
+ </button>
403
+ </div>
404
+ ))
405
+ )}
406
  </div>
407
+ </aside>
408
 
409
+ {/* ── Main chat area ─────────────────────────────────────────────────── */}
410
+ <div className="flex flex-1 flex-col min-w-0">
411
+ <div className="flex items-center justify-between border-b border-white/10 bg-black/20 backdrop-blur-xl px-4 sm:px-8 py-4 flex-shrink-0">
412
+ <div className="flex items-center gap-3 min-w-0">
413
+ <div className="flex items-center gap-2 flex-shrink-0">
414
+ <div className="h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
415
+ <span className="text-xs text-emerald-400">Live AI</span>
 
 
 
 
 
 
 
416
  </div>
417
+ <div className="h-4 w-px bg-white/10 hidden sm:block" />
418
+ <div className="min-w-0">
419
+ <h1 className="text-base font-semibold text-white truncate">BankBot AI Assistant</h1>
420
+ <p className="text-xs text-zinc-500 truncate hidden sm:block">Context-aware · Groq-powered · Personalized</p>
 
 
 
 
 
 
 
 
 
 
 
 
421
  </div>
422
+ </div>
423
+ <div className="flex items-center gap-2 flex-shrink-0">
424
+ <button
425
+ type="button"
426
+ onClick={startNewChat}
427
+ className="md:hidden flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-zinc-400"
428
+ >
429
+ <Plus className="h-3.5 w-3.5" />
430
+ New
431
+ </button>
432
+ <button
433
+ type="button"
434
+ onClick={clearCurrentChat}
435
+ disabled={historyLoading || isThinking || isStreaming || !activeSessionId}
436
+ title="Clear this conversation"
437
+ className="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs text-zinc-400 hover:text-white transition-colors disabled:opacity-40"
438
+ >
439
+ <RotateCcw className="h-3.5 w-3.5" />
440
+ <span className="hidden sm:inline">Clear</span>
441
+ </button>
442
+ <Shield className="h-3.5 w-3.5 text-emerald-400 hidden sm:block" />
443
+ </div>
444
+ </div>
445
 
446
+ <div className="flex-1 overflow-y-auto px-4 sm:px-8 py-6 space-y-5">
447
+ {(sessionsLoading || historyLoading) && (
448
+ <div className="flex justify-center py-12 text-sm text-zinc-500">Loading conversation...</div>
449
+ )}
450
+
451
+ {!sessionsLoading && !historyLoading && showWelcome && (
 
 
 
 
 
 
452
  <motion.div
453
+ initial={{ opacity: 0, scale: 0.8 }}
454
+ animate={{ opacity: 1, scale: 1 }}
455
+ transition={{ duration: 0.6, ease: "easeOut" }}
456
+ className="flex flex-col items-center gap-6 py-8"
457
  >
458
+ <AIOrb isThinking={false} />
459
+ <div className="text-center">
460
+ <p className="text-lg font-semibold text-white">Your AI Financial Twin</p>
461
+ <p className="text-sm text-zinc-400 mt-1">Ask me anything about your finances</p>
462
  </div>
463
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 w-full max-w-md">
464
+ {suggestions.map((s) => {
465
+ const Icon = s.icon;
466
+ return (
467
+ <motion.button
468
+ key={s.label}
469
+ whileHover={{ scale: 1.03 }}
470
+ whileTap={{ scale: 0.97 }}
471
+ onClick={() => sendMessage(s.label)}
472
+ className={`flex items-center gap-2 rounded-xl border px-4 py-3 text-sm font-medium transition-all ${s.bg} ${s.color} hover:brightness-110`}
473
+ >
474
+ <Icon className="h-4 w-4" />
475
+ {s.label}
476
+ </motion.button>
477
+ );
478
+ })}
479
  </div>
480
  </motion.div>
481
  )}
 
482
 
483
+ {!sessionsLoading && !historyLoading && !showWelcome &&
484
+ messages.map((msg) => (
485
+ <MessageBubble key={msg.id} message={msg} onCopy={(t) => navigator.clipboard.writeText(t).catch(() => {})} userInitial={userInitial} />
486
+ ))}
487
 
488
+ <AnimatePresence>
489
+ {isThinking && (
490
+ <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="flex gap-3">
491
+ <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-400 to-cyan-500">
492
+ <Sparkles className="h-4 w-4 text-white" />
493
+ </div>
494
+ <div className="glass rounded-2xl rounded-tl-sm px-4 py-3 border border-white/10">
495
+ <div className="flex gap-1 items-center h-4">
496
+ {[0, 1, 2].map((i) => (
497
+ <motion.div
498
+ key={i}
499
+ animate={{ y: [0, -4, 0] }}
500
+ transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
501
+ className="h-1.5 w-1.5 rounded-full bg-emerald-400"
502
+ />
503
+ ))}
504
+ </div>
505
+ </div>
506
+ </motion.div>
507
+ )}
508
+ </AnimatePresence>
509
+ <div ref={messagesEndRef} />
510
  </div>
 
511
 
512
+ {messages.length > 1 && (
513
+ <div className="flex gap-2 px-4 sm:px-8 pb-2 overflow-x-auto flex-shrink-0">
514
+ {suggestions.map((s) => {
515
+ const Icon = s.icon;
516
+ return (
517
+ <button
518
+ key={s.label}
519
+ onClick={() => sendMessage(s.label)}
520
+ disabled={isStreaming || isThinking}
521
+ className={`flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-all disabled:opacity-40 ${s.bg} ${s.color} hover:brightness-110`}
522
+ >
523
+ <Icon className="h-3 w-3" />
524
+ {s.label}
525
+ </button>
526
+ );
527
+ })}
528
+ </div>
529
+ )}
530
+
531
+ <div className="flex-shrink-0 border-t border-white/10 bg-black/20 backdrop-blur-xl px-4 sm:px-8 py-4">
532
+ <div className="flex items-end gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 focus-within:border-emerald-500/40 transition-all">
533
+ <button type="button" className="text-zinc-500 hover:text-zinc-300 transition-colors mb-0.5">
534
+ <Paperclip className="h-4 w-4" />
535
  </button>
536
+ <textarea
537
+ ref={inputRef}
538
+ value={input}
539
+ onChange={(e) => setInput(e.target.value)}
540
+ onKeyDown={(e) => {
541
+ if (e.key === "Enter" && !e.shiftKey) {
542
+ e.preventDefault();
543
+ sendMessage();
544
+ }
545
+ }}
546
+ placeholder="Ask about your finances, forecasts, fraud alerts..."
547
+ rows={1}
548
+ disabled={isStreaming || isThinking || sessionsLoading}
549
+ className="flex-1 resize-none bg-transparent text-sm text-white placeholder:text-zinc-500 focus:outline-none leading-relaxed max-h-32 disabled:opacity-50"
550
+ style={{ minHeight: "24px" }}
551
+ />
552
+ <div className="flex items-center gap-2 mb-0.5">
553
+ <button type="button" className="text-zinc-500 hover:text-zinc-300 transition-colors">
554
+ <Mic className="h-4 w-4" />
555
+ </button>
556
+ <motion.button
557
+ whileHover={{ scale: 1.05 }}
558
+ whileTap={{ scale: 0.95 }}
559
+ onClick={() => sendMessage()}
560
+ disabled={!input.trim() || isThinking || isStreaming || sessionsLoading}
561
+ className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-cyan-500 text-white disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
562
+ >
563
+ <Send className="h-3.5 w-3.5" />
564
+ </motion.button>
565
+ </div>
566
  </div>
567
+ <p className="text-center text-xs text-zinc-600 mt-2">
568
+ AI responses are for informational purposes only. Not financial advice.
569
+ </p>
570
  </div>
 
 
 
571
  </div>
572
  </div>
573
  );
frontend/src/lib/api.ts CHANGED
@@ -199,13 +199,62 @@ export const aiApi = {
199
  const qs = userId ? `?user_id=${userId}` : "";
200
  return apiFetch<{ subscriptions: Array<{ merchant: string; amount: number; recommendation: string }> }>(`/api/ai/subscriptions/optimize${qs}`);
201
  },
202
- chat: (message: string, userId?: string) => {
203
  const qs = userId ? `?user_id=${userId}` : "";
204
- return apiFetch<{ response: string }>(`/api/ai/chat${qs}`, {
205
  method: "POST",
206
- body: JSON.stringify({ message }),
207
  });
208
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  simulatePurchase: (amount: number, merchant: string, category: string, userId?: string) => {
210
  const qs = userId ? `?user_id=${userId}` : "";
211
  return apiFetch<Record<string, unknown>>(`/api/ai/simulate/purchase${qs}`, {
 
199
  const qs = userId ? `?user_id=${userId}` : "";
200
  return apiFetch<{ subscriptions: Array<{ merchant: string; amount: number; recommendation: string }> }>(`/api/ai/subscriptions/optimize${qs}`);
201
  },
202
+ chat: (message: string, userId?: string, sessionId?: string) => {
203
  const qs = userId ? `?user_id=${userId}` : "";
204
+ return apiFetch<{ response: string; session_id: string }>(`/api/ai/chat${qs}`, {
205
  method: "POST",
206
+ body: JSON.stringify({ message, session_id: sessionId }),
207
  });
208
  },
209
+ chatSessions: (userId?: string) => {
210
+ const qs = userId ? `?user_id=${userId}` : "";
211
+ return apiFetch<{
212
+ sessions: Array<{
213
+ id: string;
214
+ title: string;
215
+ created_at: string | null;
216
+ updated_at: string | null;
217
+ message_count: number;
218
+ preview: string;
219
+ }>;
220
+ count: number;
221
+ }>(`/api/ai/chat/sessions${qs}`);
222
+ },
223
+ createChatSession: (userId?: string, title = "New chat") => {
224
+ const qs = userId ? `?user_id=${userId}` : "";
225
+ return apiFetch<{
226
+ id: string;
227
+ title: string;
228
+ created_at: string | null;
229
+ updated_at: string | null;
230
+ message_count: number;
231
+ preview: string;
232
+ }>(`/api/ai/chat/sessions${qs}`, {
233
+ method: "POST",
234
+ body: JSON.stringify({ title }),
235
+ });
236
+ },
237
+ deleteChatSession: (sessionId: string, userId?: string) => {
238
+ const qs = userId ? `?user_id=${userId}` : "";
239
+ return apiFetch<{ ok: boolean }>(`/api/ai/chat/sessions/${sessionId}${qs}`, { method: "DELETE" });
240
+ },
241
+ chatHistory: (sessionId: string, userId?: string, limit = 100) => {
242
+ const params = new URLSearchParams({ session_id: sessionId, limit: String(limit) });
243
+ if (userId) params.set("user_id", userId);
244
+ return apiFetch<{
245
+ session_id: string;
246
+ messages: Array<{ id: string; role: "user" | "assistant"; content: string; created_at: string | null }>;
247
+ count: number;
248
+ }>(`/api/ai/chat/history?${params}`);
249
+ },
250
+ clearChatHistory: (sessionId: string, userId?: string) => {
251
+ const params = new URLSearchParams({ session_id: sessionId });
252
+ if (userId) params.set("user_id", userId);
253
+ return apiFetch<{ ok: boolean; message: string; session_id: string }>(
254
+ `/api/ai/chat/history?${params}`,
255
+ { method: "DELETE" }
256
+ );
257
+ },
258
  simulatePurchase: (amount: number, merchant: string, category: string, userId?: string) => {
259
  const qs = userId ? `?user_id=${userId}` : "";
260
  return apiFetch<Record<string, unknown>>(`/api/ai/simulate/purchase${qs}`, {