Martechsol commited on
Commit
11e3dbb
Β·
1 Parent(s): ec36498

Deploy RAG Chatbot to Hugging Face Spaces

Browse files
FUTURE_LIVECHAT_FEATURES.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Future Admin Panel Features (Inspired by LiveChat)
2
+
3
+ To further elevate the Admin Panel to the standard of enterprise customer service tools like **LiveChat.inc**, we can implement several advanced features. These additions would transition the panel from a passive monitoring dashboard into an active customer engagement hub.
4
+
5
+ Please review each proposed feature below. You can write your feedback directly in the **User Comments:** sections!
6
+
7
+ ---
8
+
9
+ ## 1. Human Handoff (Live Agent Takeover)
10
+ - **What it is:** The ability for an admin to pause the AI agent and manually type responses to the website visitor in real-time.
11
+ - **Why we need it:** Perfect for handling complex support queries, closing sales deals, or managing frustrated users when the AI reaches its limits.
12
+ - **UI Addition:** A text input box at the bottom of the active chat transcript and a "Take Over Chat" toggle button.
13
+
14
+ > **User Comments:**
15
+ >yes this is a good feature, i think there should be automatically this switching work , i mean if i want i can switch manually and it should switch automatically when needed, for eg: if a user asks connect me to the live agent, connect me to the human, connect me to the HR, etc it should notify me with a link (individual secured link that opens only chat of that specific user, not for all users, admin should be able to see only that specific user chat and details._), when i open that link, i could see name of the user, and previous chat that he has done with ai, then i can chat with that user, and at the end of the conversation, i can end the chat, or i can leave the chat, then ai will takeover the chat again, we have to work on it, it should be smooth and perfectly working.
16
+ when a user say to the bot that connect me to the live agent or human, or i have to talk to the HR, or supervisor, the bot should ask do you want me to call your supervisor in this chat? if user say no, then it should not transfer, and if user say yes, it should put the user in the queue untill and at the backend send the notification to the admin, when admin open that, he should see that user is in the queue, and he can take over the chat, when he end the chat, it should automatically go back to the ai mode. and if the admin doenst end the chat.
17
+ for notifications, i was thinking to integrate whatsapp notifications, like it should notify the admin/HR/supervisor om the whatsapp. these all customizations like adding whatsapp number of the supervisor, HR, or admin should be in admin portal.
18
+
19
+ ---
20
+
21
+ ## 2. Real-Time Visitor Tracking
22
+ - **What it is:** A new "Visitors" tab showing who is currently browsing the website in real-time, even before they start a chat.
23
+ - **Why we need it:** Admins can see exactly which page a user is looking at, their geographic location, device, and how long they've been on the site.
24
+ - **UI Addition:** A live data grid showing active connections and the current URL path they are on.
25
+
26
+ > **User Comments:**
27
+ > Yes, keep tracking record of visitors live, is good. also there should be a tab that shows daily users, as we can fetch the names of users, and create sessions with their names, so record should be with their names.
28
+
29
+ ---
30
+
31
+ ## 3. Proactive Chat Triggers (Automated Greetings)
32
+ - **What it is:** Instead of waiting for the user to click the chat bubble, the chatbot automatically pops open with a contextual message based on their behavior.
33
+ - **Why we need it:** Increases engagement. For example, if a user lingers on a pricing page for 30 seconds, the bot can pop up and say, *"Hi! Do you have any questions about our pricing plans?"*
34
+ - **UI Addition:** A "Triggers" settings page in the admin panel to define rules (e.g., URL contains `/pricing`, Time on page > 30s -> Send Message X).
35
+
36
+ > **User Comments:**
37
+ >This is already implemented, please dont touch this, and we will work on it later.
38
+
39
+ ---
40
+
41
+ ## 4. Chat Tagging and Categorization
42
+ - **What it is:** The ability to assign custom tags (e.g., `Sales`, `Support`, `Bug Report`, `Resolved`) to specific chat sessions.
43
+ - **Why we need it:** Makes it significantly easier to filter historical chats and analyze what topics users are asking about the most.
44
+ - **UI Addition:** A tagging interface in the right-sidebar of the active chat view and tag filters in the main session list.
45
+
46
+ > **User Comments:**
47
+ >Not now, we will work on this later.
48
+
49
+ ---
50
+
51
+ ## 5. Advanced Analytics & CSAT (Customer Satisfaction)
52
+ - **What it is:** Allowing visitors to rate the chat (πŸ‘/πŸ‘Ž or 1-5 stars) at the end of the session.
53
+ - **Why we need it:** Provides measurable data on how well the AI (or human agent) is performing.
54
+ - **UI Addition:** A new chart on the dashboard tracking positive vs. negative ratings, and a "Rating" column in the session list.
55
+
56
+ > **User Comments:**
57
+ > yes, this is a good feature, when chat get ended, a small 5 starts in a row should appear, with heading rate this chat, these shars should be empty at the start, when user clicks on 1 star then it should fill 1 star, and if he clicks on 2 stars then it should fill 2 stars, and so on, and after that a thank you message should appear, and then the chat should end. saying thankyou and the rate this chat sections should be gone.
58
+
59
+ ---
60
+
61
+ ## 6. Canned Responses (Quick Replies)
62
+ - **What it is:** Pre-saved text snippets that human admins can insert with a keyboard shortcut (e.g., typing `/pricing` instantly pastes the full pricing details).
63
+ - **Why we need it:** Drastically speeds up response times during a live human takeover.
64
+ - **UI Addition:** A "Canned Responses" management tab to add, edit, and delete shortcuts.
65
+
66
+ > **User Comments:**
67
+ > Dont touch this please, i have other plans to move forward in future.
68
+
69
+ ---
70
+
71
+ ## 7. IP Blocking & Visitor Banning
72
+ - **What it is:** A security feature to block abusive visitors or spam bots from interacting with the widget.
73
+ - **Why we need it:** Saves LLM token costs and keeps the analytics clean from spam.
74
+ - **UI Addition:** A "Block User" button inside the chat header and a "Banned IPs" management list.
75
+
76
+ > **User Comments:**
77
+ > Yes, each user is assigned a session Id, using this session id we can block the user, and then the user should not be able to chat with us, and yes if he open the chat again, it should show a message that you are blocked by the admin. there should be a tab on admin panel to show list of blocked and unblocked users, as admin can unblock them anytime with a switch button.
78
+
79
+ ---
80
+
app/admin/router.py CHANGED
@@ -312,6 +312,39 @@ async def remove_session(
312
  return JSONResponse(content={"deleted": session_id})
313
 
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  # ── Export Endpoints ─────────────────────────────────────────────────────────
316
 
317
  @admin_router.get("/api/admin/export/session/{session_id}", include_in_schema=False)
 
312
  return JSONResponse(content={"deleted": session_id})
313
 
314
 
315
+ class ReplyRequest(BaseModel):
316
+ message: str
317
+
318
+ @admin_router.post("/api/admin/sessions/{session_id}/block", include_in_schema=False)
319
+ async def block_session(session_id: str, username: str = Depends(verify_admin)):
320
+ from app.services.session_store import update_session_field
321
+ await update_session_field(session_id, "blocked", True)
322
+ return {"status": "blocked"}
323
+
324
+ @admin_router.post("/api/admin/sessions/{session_id}/unblock", include_in_schema=False)
325
+ async def unblock_session(session_id: str, username: str = Depends(verify_admin)):
326
+ from app.services.session_store import update_session_field
327
+ await update_session_field(session_id, "blocked", False)
328
+ return {"status": "unblocked"}
329
+
330
+ @admin_router.post("/api/admin/sessions/{session_id}/takeover", include_in_schema=False)
331
+ async def takeover_session(session_id: str, username: str = Depends(verify_admin)):
332
+ from app.services.session_store import update_session_field
333
+ await update_session_field(session_id, "status", "HUMAN_TAKEOVER")
334
+ return {"status": "human_takeover"}
335
+
336
+ @admin_router.post("/api/admin/sessions/{session_id}/end_takeover", include_in_schema=False)
337
+ async def end_takeover_session(session_id: str, username: str = Depends(verify_admin)):
338
+ from app.services.session_store import update_session_field
339
+ await update_session_field(session_id, "status", "AI")
340
+ return {"status": "ai"}
341
+
342
+ @admin_router.post("/api/admin/sessions/{session_id}/reply", include_in_schema=False)
343
+ async def reply_session(session_id: str, req: ReplyRequest, username: str = Depends(verify_admin)):
344
+ from app.services.session_store import save_message
345
+ asyncio.create_task(save_message(session_id, "", req.message))
346
+ return {"status": "sent"}
347
+
348
  # ── Export Endpoints ─────────────────────────────────────────────────────────
349
 
350
  @admin_router.get("/api/admin/export/session/{session_id}", include_in_schema=False)
app/admin/templates/admin.html CHANGED
@@ -377,6 +377,14 @@
377
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
378
  Live Sessions
379
  </div>
 
 
 
 
 
 
 
 
380
 
381
  <div class="nav-section-title">Quick Links</div>
382
  <a href="/widget" target="_blank" class="quick-link">
@@ -451,6 +459,8 @@
451
  <p id="chat-meta">Session ID: -</p>
452
  </div>
453
  <div class="chat-header-actions">
 
 
454
  <button class="btn-secondary" id="btn-export-single" style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Export</button>
455
  <button class="btn-secondary" id="btn-delete-session" style="padding: 6px 12px; font-size:12px; color:var(--red); border-color:rgba(239,68,68,0.2)">Delete</button>
456
  </div>
@@ -458,6 +468,10 @@
458
  <div class="chat-messages" id="messages-area">
459
  <!-- Populated by JS -->
460
  </div>
 
 
 
 
461
  </div>
462
  </div>
463
  </div>
@@ -504,6 +518,9 @@
504
  let _creds = null;
505
  let _allSessions = [];
506
  let _activeId = null;
 
 
 
507
  let _refreshInterval = null;
508
  let _chartInstance = null;
509
  const STORAGE_KEY = 'martech_admin_creds';
@@ -629,13 +646,24 @@
629
  v.style.display = 'none';
630
  v.classList.remove('active');
631
  });
632
- const v = $('view-' + view);
633
- v.style.display = view === 'sessions' ? 'flex' : 'block';
 
 
 
 
634
  setTimeout(() => v.classList.add('active'), 10);
635
- $('topbar-title').textContent = view === 'dashboard' ? 'Dashboard Overview' : 'Live Chat Sessions';
 
 
 
 
636
 
637
  if(view === 'dashboard') loadAnalytics();
638
- if(view === 'sessions') loadSessions();
 
 
 
639
  }
640
 
641
  // ── Dashboard Analytics ──
@@ -709,7 +737,14 @@
709
 
710
  function renderSessionList(sessions) {
711
  const q = $('search-inp').value.toLowerCase();
712
- const filtered = sessions.filter(s => s.session_id.toLowerCase().includes(q) || (s.user_name||'').toLowerCase().includes(q));
 
 
 
 
 
 
 
713
 
714
  if(!filtered.length) {
715
  $('session-list').innerHTML = `<div style="text-align:center;color:var(--text3);padding:40px;font-size:13px">No sessions found</div>`;
@@ -769,13 +804,43 @@
769
  <div class="msg-bubble">${esc(m.question)}</div>
770
  </div>
771
  <div class="msg-wrapper bot">
772
- <div class="msg-meta">AI Agent β€’ ${fmtDate(m.timestamp).split(' ')[1] || ''}</div>
773
  <div class="msg-bubble">${esc(m.answer)}</div>
774
  </div>
775
  `;
776
  });
777
  $('messages-area').innerHTML = html;
778
  $('messages-area').scrollTop = $('messages-area').scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
779
  } catch(e) {
780
  $('messages-area').innerHTML = `<div style="color:var(--red);text-align:center;padding:20px">Failed to load chat</div>`;
781
  }
@@ -797,6 +862,49 @@
797
  } catch(e) { showToast('Delete failed', 'error'); }
798
  };
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  // Auto Refresh
801
  function setupAutoRefresh() {
802
  clearInterval(_refreshInterval);
 
377
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
378
  Live Sessions
379
  </div>
380
+ <div class="nav-item" data-view="daily" id="nav-daily">
381
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
382
+ Daily Users
383
+ </div>
384
+ <div class="nav-item" data-view="blocked" id="nav-blocked">
385
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
386
+ Blocked Users
387
+ </div>
388
 
389
  <div class="nav-section-title">Quick Links</div>
390
  <a href="/widget" target="_blank" class="quick-link">
 
459
  <p id="chat-meta">Session ID: -</p>
460
  </div>
461
  <div class="chat-header-actions">
462
+ <button class="btn-secondary" id="btn-block-user" style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Block</button>
463
+ <button class="btn-secondary" id="btn-takeover" style="padding: 6px 12px; font-size:12px; border-color:var(--accent); color:var(--accent)">Take Over</button>
464
  <button class="btn-secondary" id="btn-export-single" style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Export</button>
465
  <button class="btn-secondary" id="btn-delete-session" style="padding: 6px 12px; font-size:12px; color:var(--red); border-color:rgba(239,68,68,0.2)">Delete</button>
466
  </div>
 
468
  <div class="chat-messages" id="messages-area">
469
  <!-- Populated by JS -->
470
  </div>
471
+ <div class="chat-reply-area hidden" id="chat-reply-area" style="padding: 20px; border-top: 1px solid var(--border); background: var(--surface); display: flex; gap: 12px;">
472
+ <input type="text" id="inp-admin-reply" placeholder="Type a message to the user..." style="flex: 1; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; font-size: 14px; outline: none;" />
473
+ <button class="btn-primary" id="btn-send-reply" style="width: auto; padding: 12px 24px;">Send</button>
474
+ </div>
475
  </div>
476
  </div>
477
  </div>
 
518
  let _creds = null;
519
  let _allSessions = [];
520
  let _activeId = null;
521
+ let _activeStatus = 'AI';
522
+ let _activeBlocked = false;
523
+ let _currentFilter = 'sessions';
524
  let _refreshInterval = null;
525
  let _chartInstance = null;
526
  const STORAGE_KEY = 'martech_admin_creds';
 
646
  v.style.display = 'none';
647
  v.classList.remove('active');
648
  });
649
+
650
+ let targetView = view;
651
+ if (view === 'daily' || view === 'blocked') targetView = 'sessions';
652
+
653
+ const v = $('view-' + targetView);
654
+ v.style.display = targetView === 'sessions' ? 'flex' : 'block';
655
  setTimeout(() => v.classList.add('active'), 10);
656
+
657
+ if (view === 'dashboard') $('topbar-title').textContent = 'Dashboard Overview';
658
+ else if (view === 'sessions') $('topbar-title').textContent = 'All Live Chat Sessions';
659
+ else if (view === 'daily') $('topbar-title').textContent = 'Today\'s Users';
660
+ else if (view === 'blocked') $('topbar-title').textContent = 'Blocked Users';
661
 
662
  if(view === 'dashboard') loadAnalytics();
663
+ if(['sessions', 'daily', 'blocked'].includes(view)) {
664
+ _currentFilter = view;
665
+ loadSessions();
666
+ }
667
  }
668
 
669
  // ── Dashboard Analytics ──
 
737
 
738
  function renderSessionList(sessions) {
739
  const q = $('search-inp').value.toLowerCase();
740
+ let filtered = sessions.filter(s => s.session_id.toLowerCase().includes(q) || (s.user_name||'').toLowerCase().includes(q));
741
+
742
+ if (_currentFilter === 'blocked') {
743
+ filtered = filtered.filter(s => s.blocked === true);
744
+ } else if (_currentFilter === 'daily') {
745
+ const today = new Date().toISOString().split('T')[0];
746
+ filtered = filtered.filter(s => (s.created_at || '').startsWith(today));
747
+ }
748
 
749
  if(!filtered.length) {
750
  $('session-list').innerHTML = `<div style="text-align:center;color:var(--text3);padding:40px;font-size:13px">No sessions found</div>`;
 
804
  <div class="msg-bubble">${esc(m.question)}</div>
805
  </div>
806
  <div class="msg-wrapper bot">
807
+ <div class="msg-meta">${m.answer === 'Admin message' ? 'Human Agent' : 'AI Agent'} β€’ ${fmtDate(m.timestamp).split(' ')[1] || ''}</div>
808
  <div class="msg-bubble">${esc(m.answer)}</div>
809
  </div>
810
  `;
811
  });
812
  $('messages-area').innerHTML = html;
813
  $('messages-area').scrollTop = $('messages-area').scrollHeight;
814
+
815
+ _activeStatus = data.status || 'AI';
816
+ _activeBlocked = data.blocked || false;
817
+
818
+ // Update Takeover Button
819
+ const btnTakeover = $('btn-takeover');
820
+ if (_activeStatus === 'HUMAN_TAKEOVER') {
821
+ btnTakeover.textContent = 'End Chat (AI)';
822
+ btnTakeover.style.color = '#ef4444';
823
+ btnTakeover.style.borderColor = '#ef4444';
824
+ $('chat-reply-area').classList.remove('hidden');
825
+ } else {
826
+ btnTakeover.textContent = 'Take Over';
827
+ btnTakeover.style.color = 'var(--accent)';
828
+ btnTakeover.style.borderColor = 'var(--accent)';
829
+ $('chat-reply-area').classList.add('hidden');
830
+ }
831
+
832
+ // Update Block Button
833
+ const btnBlock = $('btn-block-user');
834
+ if (_activeBlocked) {
835
+ btnBlock.textContent = 'Unblock User';
836
+ btnBlock.style.color = '#10b981';
837
+ btnBlock.style.borderColor = '#10b981';
838
+ } else {
839
+ btnBlock.textContent = 'Block User';
840
+ btnBlock.style.color = 'var(--text2)';
841
+ btnBlock.style.borderColor = 'var(--border)';
842
+ }
843
+
844
  } catch(e) {
845
  $('messages-area').innerHTML = `<div style="color:var(--red);text-align:center;padding:20px">Failed to load chat</div>`;
846
  }
 
862
  } catch(e) { showToast('Delete failed', 'error'); }
863
  };
864
 
865
+ $('btn-takeover').onclick = async () => {
866
+ if(!_activeId) return;
867
+ try {
868
+ const isTakeover = _activeStatus !== 'HUMAN_TAKEOVER';
869
+ const endpoint = isTakeover ? 'takeover' : 'end_takeover';
870
+ const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/' + endpoint, { method: 'POST', headers: {Authorization: authHeader()} });
871
+ if(r.ok) {
872
+ showToast(isTakeover ? 'Human takeover active' : 'Chat handed back to AI');
873
+ openSession(_activeId);
874
+ }
875
+ } catch(e) { showToast('Action failed', 'error'); }
876
+ };
877
+
878
+ $('btn-block-user').onclick = async () => {
879
+ if(!_activeId) return;
880
+ try {
881
+ const endpoint = _activeBlocked ? 'unblock' : 'block';
882
+ const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/' + endpoint, { method: 'POST', headers: {Authorization: authHeader()} });
883
+ if(r.ok) {
884
+ showToast(_activeBlocked ? 'User unblocked' : 'User blocked');
885
+ openSession(_activeId);
886
+ }
887
+ } catch(e) { showToast('Action failed', 'error'); }
888
+ };
889
+
890
+ $('btn-send-reply').onclick = async () => {
891
+ if(!_activeId) return;
892
+ const msg = $('inp-admin-reply').value.trim();
893
+ if(!msg) return;
894
+ $('inp-admin-reply').value = '';
895
+ try {
896
+ const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/reply', {
897
+ method: 'POST',
898
+ headers: {'Content-Type': 'application/json', Authorization: authHeader()},
899
+ body: JSON.stringify({message: msg})
900
+ });
901
+ if(r.ok) {
902
+ openSession(_activeId);
903
+ }
904
+ } catch(e) { showToast('Send failed', 'error'); }
905
+ };
906
+ $('inp-admin-reply').onkeydown = e => { if(e.key==='Enter') $('btn-send-reply').click(); };
907
+
908
  // Auto Refresh
909
  function setupAutoRefresh() {
910
  clearInterval(_refreshInterval);
app/api/schemas.py CHANGED
@@ -30,3 +30,11 @@ class SessionUpdateNameRequest(BaseModel):
30
  class SessionInitRequest(BaseModel):
31
  session_id: str
32
  user_name: str | None = Field(default=None, description="User name to associate with the session at creation time")
 
 
 
 
 
 
 
 
 
30
  class SessionInitRequest(BaseModel):
31
  session_id: str
32
  user_name: str | None = Field(default=None, description="User name to associate with the session at creation time")
33
+
34
+ class RateRequest(BaseModel):
35
+ session_id: str
36
+ rating: int = Field(..., ge=1, le=5)
37
+
38
+ class PollRequest(BaseModel):
39
+ session_id: str
40
+ last_id: int = Field(default=0)
app/core/config.py CHANGED
@@ -53,6 +53,11 @@ class Settings(BaseSettings):
53
  admin_user: str = Field(default="martech_admin", alias="ADMIN_USER")
54
  admin_pass: str = Field(default="martech_admin_303", alias="ADMIN_PASS")
55
 
 
 
 
 
 
56
 
57
  @lru_cache
58
  def get_settings() -> Settings:
 
53
  admin_user: str = Field(default="martech_admin", alias="ADMIN_USER")
54
  admin_pass: str = Field(default="martech_admin_303", alias="ADMIN_PASS")
55
 
56
+ # WhatsApp API Settings (Meta Cloud API)
57
+ meta_whatsapp_phone_id: str = Field(default="", alias="META_WHATSAPP_PHONE_ID")
58
+ meta_whatsapp_token: str = Field(default="", alias="META_WHATSAPP_TOKEN")
59
+ meta_whatsapp_admin_number: str = Field(default="", alias="META_WHATSAPP_ADMIN_NUMBER")
60
+
61
 
62
  @lru_cache
63
  def get_settings() -> Settings:
app/main.py CHANGED
@@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
6
  from fastapi.staticfiles import StaticFiles
7
  import os
8
 
9
- from app.api.schemas import ChatRequest, ChatResponse, HealthResponse, SessionUpdateNameRequest, SessionInitRequest
10
  from app.core.config import get_settings
11
  from app.services.embeddings import EmbeddingService
12
  from app.services.llm import LLMService
@@ -137,6 +137,28 @@ def health() -> HealthResponse:
137
  h = vector_store.health()
138
  return HealthResponse(status="ok", **h)
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  @fastapi_app.post("/api/chat", response_model=ChatResponse)
142
  async def chat(
@@ -155,11 +177,46 @@ async def chat(
155
 
156
  logger.info("Incoming chat request: msg='%s', sid='%s'", payload.message[:30], payload.session_id)
157
 
158
- # Fetch user_name if available for personalization
159
  user_name = None
160
  if payload.session_id:
161
  sess_data = await _ss.get_session(payload.session_id)
162
- user_name = sess_data.get("user_name")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
  try:
165
  result = await pipeline.chat(payload.message, payload.history, user_name=user_name)
@@ -226,9 +283,40 @@ async def get_session_history(session_id: str):
226
 
227
  history = []
228
  for msg in data.get("messages", []):
229
- history.append({"role": "user", "content": msg["question"]})
230
- history.append({"role": "assistant", "content": msg["answer"]})
231
- return {"history": history, "user_name": data.get("user_name", "")}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
 
234
  # ── Mount Gradio at /ui β€” MUST be last, wraps the FastAPI app ─────────────────
 
6
  from fastapi.staticfiles import StaticFiles
7
  import os
8
 
9
+ from app.api.schemas import ChatRequest, ChatResponse, HealthResponse, SessionUpdateNameRequest, SessionInitRequest, RateRequest, PollRequest
10
  from app.core.config import get_settings
11
  from app.services.embeddings import EmbeddingService
12
  from app.services.llm import LLMService
 
137
  h = vector_store.health()
138
  return HealthResponse(status="ok", **h)
139
 
140
+ async def send_whatsapp_message(text: str):
141
+ if not settings.meta_whatsapp_phone_id or not settings.meta_whatsapp_token or not settings.meta_whatsapp_admin_number:
142
+ logger.warning("WhatsApp settings missing, skipping notification.")
143
+ return
144
+ import httpx
145
+ url = f"https://graph.facebook.com/v17.0/{settings.meta_whatsapp_phone_id}/messages"
146
+ headers = {
147
+ "Authorization": f"Bearer {settings.meta_whatsapp_token}",
148
+ "Content-Type": "application/json"
149
+ }
150
+ payload = {
151
+ "messaging_product": "whatsapp",
152
+ "to": settings.meta_whatsapp_admin_number,
153
+ "type": "text",
154
+ "text": {"body": text}
155
+ }
156
+ try:
157
+ async with httpx.AsyncClient() as client:
158
+ await client.post(url, headers=headers, json=payload, timeout=10.0)
159
+ except Exception as e:
160
+ logger.error(f"Failed to send WhatsApp message: {e}")
161
+
162
 
163
  @fastapi_app.post("/api/chat", response_model=ChatResponse)
164
  async def chat(
 
177
 
178
  logger.info("Incoming chat request: msg='%s', sid='%s'", payload.message[:30], payload.session_id)
179
 
180
+ # Fetch user_name and handle session status
181
  user_name = None
182
  if payload.session_id:
183
  sess_data = await _ss.get_session(payload.session_id)
184
+ if sess_data:
185
+ user_name = sess_data.get("user_name")
186
+ if sess_data.get("blocked"):
187
+ return ChatResponse(reply="You are blocked by the admin.", retrieved_chunks=[])
188
+
189
+ status = sess_data.get("status", "AI")
190
+ msg_lower = payload.message.lower()
191
+
192
+ if status == "HUMAN_TAKEOVER":
193
+ asyncio.create_task(_ss.save_message(payload.session_id, payload.message, ""))
194
+ return ChatResponse(reply="", retrieved_chunks=[])
195
+
196
+ if status == "QUEUED":
197
+ reply_msg = "Please wait, an agent will be with you shortly."
198
+ asyncio.create_task(_ss.save_message(payload.session_id, payload.message, reply_msg))
199
+ return ChatResponse(reply=reply_msg, retrieved_chunks=[])
200
+
201
+ if status == "CONFIRMING_TRANSFER":
202
+ if msg_lower in ["yes", "y", "yeah", "yep", "sure", "ok", "okay"]:
203
+ await _ss.update_session_field(payload.session_id, "status", "QUEUED")
204
+ reply_msg = "You are now in the queue. An agent will join shortly."
205
+ asyncio.create_task(_ss.save_message(payload.session_id, payload.message, reply_msg))
206
+ asyncio.create_task(send_whatsapp_message(f"🚨 New Human Handoff Request!\nUser: {user_name}\nSession ID: {payload.session_id}\nPlease check the Admin Panel."))
207
+ return ChatResponse(reply=reply_msg, retrieved_chunks=[])
208
+ else:
209
+ await _ss.update_session_field(payload.session_id, "status", "AI")
210
+ reply_msg = "Transfer cancelled. How can I help you?"
211
+ asyncio.create_task(_ss.save_message(payload.session_id, payload.message, reply_msg))
212
+ return ChatResponse(reply=reply_msg, retrieved_chunks=[])
213
+
214
+ # AI Mode: Check for human takeover keywords
215
+ if any(kw in msg_lower for kw in ["human", "agent", "hr", "supervisor", "representative", "real person"]):
216
+ await _ss.update_session_field(payload.session_id, "status", "CONFIRMING_TRANSFER")
217
+ reply_msg = "Do you want me to call your supervisor/agent in this chat? (yes/no)"
218
+ asyncio.create_task(_ss.save_message(payload.session_id, payload.message, reply_msg))
219
+ return ChatResponse(reply=reply_msg, retrieved_chunks=[])
220
 
221
  try:
222
  result = await pipeline.chat(payload.message, payload.history, user_name=user_name)
 
283
 
284
  history = []
285
  for msg in data.get("messages", []):
286
+ if msg.get("question"):
287
+ history.append({"role": "user", "content": msg["question"]})
288
+ if msg.get("answer"):
289
+ history.append({"role": "assistant", "content": msg["answer"]})
290
+ return {"history": history, "user_name": data.get("user_name", ""), "status": data.get("status", "AI")}
291
+
292
+
293
+ @fastapi_app.post("/api/chat/poll")
294
+ async def chat_poll(payload: PollRequest):
295
+ """Frontend polls this when in HUMAN_TAKEOVER or QUEUED mode to get new admin messages."""
296
+ sess = await _ss.get_session(payload.session_id)
297
+ if not sess:
298
+ return {"status": "AI", "new_messages": [], "last_id": payload.last_id}
299
+
300
+ status = sess.get("status", "AI")
301
+ messages = sess.get("messages", [])
302
+
303
+ new_msgs = []
304
+ for m in messages:
305
+ if m["id"] > payload.last_id:
306
+ # Only return admin answers or queued system responses as assistant
307
+ if m.get("answer"):
308
+ new_msgs.append({"id": m["id"], "role": "assistant", "content": m["answer"]})
309
+
310
+ max_id = max([m["id"] for m in messages]) if messages else payload.last_id
311
+ return {"status": status, "new_messages": new_msgs, "last_id": max_id}
312
+
313
+
314
+ @fastapi_app.post("/api/chat/rate")
315
+ async def chat_rate(payload: RateRequest):
316
+ """Saves a 1-5 star rating for the session."""
317
+ await _ss.update_session_field(payload.session_id, "rating", payload.rating)
318
+ return {"status": "ok"}
319
+
320
 
321
 
322
  # ── Mount Gradio at /ui β€” MUST be last, wraps the FastAPI app ─────────────────
app/services/session_store.py CHANGED
@@ -83,6 +83,9 @@ async def ensure_session(session_id: str) -> None:
83
  "last_active": _now_iso(),
84
  "message_count": 0,
85
  "messages": [],
 
 
 
86
  }
87
  try:
88
  await asyncio.to_thread(_write_json, path, skeleton)
@@ -113,6 +116,9 @@ async def save_message(session_id: str, question: str, answer: str) -> None:
113
  "last_active": _now_iso(),
114
  "message_count": 0,
115
  "messages": [],
 
 
 
116
  }
117
 
118
  timestamp = _now_iso()
@@ -152,6 +158,9 @@ async def get_all_sessions() -> list:
152
  "created_at": data.get("created_at", ""),
153
  "last_active": data.get("last_active", ""),
154
  "message_count": data.get("message_count", 0),
 
 
 
155
  })
156
  except Exception as exc:
157
  logger.warning("session_store: could not read %s: %s", fpath, exc)
@@ -324,6 +333,9 @@ async def rename_session(old_id: str, new_name: str) -> str:
324
  "last_active": _now_iso(),
325
  "message_count": 0,
326
  "messages": [],
 
 
 
327
  }
328
  await asyncio.to_thread(_write_json, new_path, skeleton)
329
  return new_id
@@ -349,3 +361,21 @@ def _read_json(path: Path) -> dict:
349
 
350
  def _list_json_files() -> list:
351
  return list(_get_sessions_dir().glob("*.json"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  "last_active": _now_iso(),
84
  "message_count": 0,
85
  "messages": [],
86
+ "status": "AI",
87
+ "blocked": False,
88
+ "rating": None,
89
  }
90
  try:
91
  await asyncio.to_thread(_write_json, path, skeleton)
 
116
  "last_active": _now_iso(),
117
  "message_count": 0,
118
  "messages": [],
119
+ "status": "AI",
120
+ "blocked": False,
121
+ "rating": None,
122
  }
123
 
124
  timestamp = _now_iso()
 
158
  "created_at": data.get("created_at", ""),
159
  "last_active": data.get("last_active", ""),
160
  "message_count": data.get("message_count", 0),
161
+ "status": data.get("status", "AI"),
162
+ "blocked": data.get("blocked", False),
163
+ "rating": data.get("rating", None),
164
  })
165
  except Exception as exc:
166
  logger.warning("session_store: could not read %s: %s", fpath, exc)
 
333
  "last_active": _now_iso(),
334
  "message_count": 0,
335
  "messages": [],
336
+ "status": "AI",
337
+ "blocked": False,
338
+ "rating": None,
339
  }
340
  await asyncio.to_thread(_write_json, new_path, skeleton)
341
  return new_id
 
361
 
362
  def _list_json_files() -> list:
363
  return list(_get_sessions_dir().glob("*.json"))
364
+
365
+
366
+ async def update_session_field(session_id: str, field: str, value) -> bool:
367
+ """Generic helper to update a single root field in the session JSON."""
368
+ path = _get_session_path(session_id)
369
+ lock = await _get_lock(session_id)
370
+ async with lock:
371
+ try:
372
+ if not path.exists():
373
+ return False
374
+ data = await asyncio.to_thread(_read_json, path)
375
+ data[field] = value
376
+ await asyncio.to_thread(_write_json, path, data)
377
+ logger.info("session_store: updated %s for session %s", field, session_id)
378
+ return True
379
+ except Exception as exc:
380
+ logger.error("session_store: failed to update %s for session %s: %s", field, session_id, exc)
381
+ return False
static/addon.html CHANGED
@@ -87,6 +87,7 @@
87
  background: #10b981;
88
  border-radius: 50%;
89
  animation: pulse-dot 2s infinite;
 
90
  }
91
 
92
  #martech-chat-header span {
@@ -95,6 +96,20 @@
95
  font-size: 15px;
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  /* Custom Chat Area Styling */
99
  #chat-messages {
100
  flex: 1;
@@ -259,8 +274,11 @@
259
 
260
  <div id="martech-chat-box">
261
  <div id="martech-chat-header">
262
- <div class="dot"></div>
263
- <span>Martechsol Assistant</span>
 
 
 
264
  </div>
265
 
266
  <!-- Custom Chat UI replacing iframe -->
@@ -278,6 +296,15 @@
278
  </svg>
279
  </button>
280
  </div>
 
 
 
 
 
 
 
 
 
281
  </div>
282
  </div>
283
 
@@ -314,6 +341,8 @@
314
  const API_URL = "https://joedown11-chatbot21.hf.space/api/chat";
315
  const BASE_URL = API_URL.replace("/api/chat", "");
316
  let chatHistory = [];
 
 
317
 
318
  // ── Session ID: generate once, persist in localStorage ──
319
  let sessionId = localStorage.getItem('martech_session_id');
@@ -444,11 +473,19 @@
444
  console.warn('Martech: background session pre-creation failed', e);
445
  }
446
 
447
- // Now fetch history using the finalized session ID
448
  try {
449
  const hResp = await fetch(BASE_URL + "/api/session/history/" + sessionId);
450
  if (hResp.ok) {
451
  const hData = await hResp.json();
 
 
 
 
 
 
 
 
 
452
  if (hData.history && hData.history.length > 0) {
453
  messagesContainer.innerHTML = '';
454
  chatHistory = hData.history;
@@ -497,6 +534,39 @@
497
  // Fallback: Check every 15 seconds (virtually zero cost once session is named)
498
  setInterval(_attemptSync, 15000);
499
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
  }
501
 
502
  initUserSession();
@@ -616,6 +686,55 @@
616
  inputField.addEventListener('keypress', function(e) {
617
  if (e.key === 'Enter') handleSend();
618
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
 
620
  })();
621
  </script>
 
87
  background: #10b981;
88
  border-radius: 50%;
89
  animation: pulse-dot 2s infinite;
90
+ transition: background 0.3s;
91
  }
92
 
93
  #martech-chat-header span {
 
96
  font-size: 15px;
97
  }
98
 
99
+ .header-actions { margin-left: auto; display: flex; gap: 8px; }
100
+ .btn-end-chat { background: #fee2e2; color: #ef4444; border: none; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
101
+ .btn-end-chat:hover { background: #fecaca; }
102
+
103
+ #rating-overlay {
104
+ position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95);
105
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
106
+ z-index: 100; opacity: 0; visibility: hidden; transition: all 0.3s;
107
+ }
108
+ #rating-overlay.show { opacity: 1; visibility: visible; }
109
+ .stars { display: flex; gap: 8px; font-size: 32px; color: #cbd5e1; cursor: pointer; margin: 16px 0; }
110
+ .stars span { transition: color 0.2s; }
111
+ .stars span:hover, .stars span.active { color: #f59e0b; }
112
+
113
  /* Custom Chat Area Styling */
114
  #chat-messages {
115
  flex: 1;
 
274
 
275
  <div id="martech-chat-box">
276
  <div id="martech-chat-header">
277
+ <div class="dot" id="chat-status-dot"></div>
278
+ <span id="chat-header-title">Martechsol Assistant</span>
279
+ <div class="header-actions">
280
+ <button class="btn-end-chat" id="btn-end-chat">End Chat</button>
281
+ </div>
282
  </div>
283
 
284
  <!-- Custom Chat UI replacing iframe -->
 
296
  </svg>
297
  </button>
298
  </div>
299
+
300
+ <div id="rating-overlay">
301
+ <h3 style="color:#1e293b; margin-bottom:8px">Rate Your Experience</h3>
302
+ <p style="color:#64748b; font-size:14px">How was your chat?</p>
303
+ <div class="stars" id="rating-stars">
304
+ <span data-val="1">β˜…</span><span data-val="2">β˜…</span><span data-val="3">β˜…</span><span data-val="4">β˜…</span><span data-val="5">β˜…</span>
305
+ </div>
306
+ <button id="btn-submit-rating" style="background:#10b981; color:#fff; border:none; padding:10px 24px; border-radius:8px; font-weight:600; cursor:pointer; margin-top:8px">Submit & Close</button>
307
+ </div>
308
  </div>
309
  </div>
310
 
 
341
  const API_URL = "https://joedown11-chatbot21.hf.space/api/chat";
342
  const BASE_URL = API_URL.replace("/api/chat", "");
343
  let chatHistory = [];
344
+ let _pollInterval = null;
345
+ let _lastPollId = 0;
346
 
347
  // ── Session ID: generate once, persist in localStorage ──
348
  let sessionId = localStorage.getItem('martech_session_id');
 
473
  console.warn('Martech: background session pre-creation failed', e);
474
  }
475
 
 
476
  try {
477
  const hResp = await fetch(BASE_URL + "/api/session/history/" + sessionId);
478
  if (hResp.ok) {
479
  const hData = await hResp.json();
480
+
481
+ if (hData.status === 'HUMAN_TAKEOVER') {
482
+ document.getElementById('chat-status-dot').style.background = '#8b5cf6';
483
+ document.getElementById('chat-header-title').textContent = 'Live Agent (Admin)';
484
+ } else {
485
+ document.getElementById('chat-status-dot').style.background = '#10b981';
486
+ document.getElementById('chat-header-title').textContent = 'Martechsol Assistant';
487
+ }
488
+
489
  if (hData.history && hData.history.length > 0) {
490
  messagesContainer.innerHTML = '';
491
  chatHistory = hData.history;
 
534
  // Fallback: Check every 15 seconds (virtually zero cost once session is named)
535
  setInterval(_attemptSync, 15000);
536
  }
537
+
538
+ // Setup polling for admin messages
539
+ if (_pollInterval) clearInterval(_pollInterval);
540
+ _pollInterval = setInterval(pollMessages, 3000);
541
+ }
542
+
543
+ async function pollMessages() {
544
+ if (!sessionId) return;
545
+ try {
546
+ const r = await fetch(BASE_URL + "/api/chat/poll", {
547
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
548
+ body: JSON.stringify({ session_id: sessionId, last_id: _lastPollId })
549
+ });
550
+ if (r.ok) {
551
+ const data = await r.json();
552
+ _lastPollId = data.last_id;
553
+
554
+ if (data.status === 'HUMAN_TAKEOVER') {
555
+ document.getElementById('chat-status-dot').style.background = '#8b5cf6';
556
+ document.getElementById('chat-header-title').textContent = 'Live Agent (Admin)';
557
+ } else {
558
+ document.getElementById('chat-status-dot').style.background = '#10b981';
559
+ document.getElementById('chat-header-title').textContent = 'Martechsol Assistant';
560
+ }
561
+
562
+ if (data.new_messages && data.new_messages.length > 0) {
563
+ data.new_messages.forEach(m => {
564
+ appendMessage('bot', m.content);
565
+ chatHistory.push({ role: "assistant", content: m.content });
566
+ });
567
+ }
568
+ }
569
+ } catch(e) {}
570
  }
571
 
572
  initUserSession();
 
686
  inputField.addEventListener('keypress', function(e) {
687
  if (e.key === 'Enter') handleSend();
688
  });
689
+
690
+ // Rating & End Chat Logic
691
+ const ratingOverlay = document.getElementById('rating-overlay');
692
+ const stars = document.querySelectorAll('#rating-stars span');
693
+ let _selectedRating = 0;
694
+
695
+ document.getElementById('btn-end-chat').addEventListener('click', () => {
696
+ ratingOverlay.classList.add('show');
697
+ });
698
+
699
+ stars.forEach(s => {
700
+ s.addEventListener('click', () => {
701
+ _selectedRating = parseInt(s.dataset.val);
702
+ stars.forEach(st => {
703
+ st.classList.toggle('active', parseInt(st.dataset.val) <= _selectedRating);
704
+ });
705
+ });
706
+ });
707
+
708
+ document.getElementById('btn-submit-rating').addEventListener('click', async () => {
709
+ if (_selectedRating > 0) {
710
+ try {
711
+ await fetch(BASE_URL + "/api/chat/rate", {
712
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
713
+ body: JSON.stringify({ session_id: sessionId, rating: _selectedRating })
714
+ });
715
+ } catch(e) {}
716
+ }
717
+
718
+ // Clean up session completely
719
+ localStorage.removeItem('martech_session_id');
720
+ sessionId = null;
721
+ chatHistory = [];
722
+ _lastPollId = 0;
723
+ messagesContainer.innerHTML = '<div class="chat-message bot">Welcome! How can I help you today?</div>';
724
+ ratingOverlay.classList.remove('show');
725
+
726
+ document.getElementById('chat-status-dot').style.background = '#10b981';
727
+ document.getElementById('chat-header-title').textContent = 'Martechsol Assistant';
728
+
729
+ fab.click(); // Close chat UI
730
+
731
+ // Prep for next open
732
+ setTimeout(() => {
733
+ sessionId = 'user-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9);
734
+ localStorage.setItem('martech_session_id', sessionId);
735
+ initUserSession();
736
+ }, 500);
737
+ });
738
 
739
  })();
740
  </script>