Jul 9 - Chat - save chat sessions
This commit is contained in:
@@ -319,6 +319,17 @@ support_ticket_replies: id, ticket_id (FK→support_tickets CASCADE), user_id (F
|
|||||||
body TEXT, created_at DATETIME
|
body TEXT, created_at DATETIME
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SupportChatSession / SupportChatMessage (Phase 37)
|
||||||
|
|
||||||
|
```
|
||||||
|
support_chat_sessions: id, customer_id (FK→users CASCADE, indexed),
|
||||||
|
created_at, updated_at (indexed)
|
||||||
|
support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE, indexed),
|
||||||
|
role ('user'|'assistant'), content TEXT, created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
Persists the customer AI support chat. `chat_message()` writes both the user turn and the assistant reply into the session (creating one lazily on the first message; `session.updated_at` bumped each turn). `chat()` reloads the customer's **most recent** session into the chat window for continuity (unless `?new=1`). Read-only history views exist for the customer (`/support/my-conversations`) and staff (`/support/admin/conversations`). `SupportChatSession.preview` = first user message; `.message_count` for list views. See §18 "Support Chat".
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email)
|
- Customer submits ticket via chat page modal → status `open` → admins notified (in-app + email)
|
||||||
- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`)
|
- Admin replies → status auto-advances to `answered` → customer notified (in-app + email, link to `/support/my-tickets/<id>`)
|
||||||
@@ -454,7 +465,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi
|
|||||||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
||||||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
|
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
|
||||||
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
|
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
|
||||||
| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
| `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||||
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
|
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
|
||||||
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
|
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
|
||||||
| `api` | `/api/v1` | parent blueprint |
|
| `api` | `/api/v1` | parent blueprint |
|
||||||
@@ -761,7 +772,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
|
|||||||
→ phase33_contract_recipients
|
→ phase33_contract_recipients
|
||||||
→ phase34_facility_qr
|
→ phase34_facility_qr
|
||||||
→ phase35_issue_handler
|
→ phase35_issue_handler
|
||||||
→ phase36_scheduled_insp ← HEAD
|
→ phase36_scheduled_insp
|
||||||
|
→ phase37_support_chat ← HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
### phase21_performance_indexes
|
### phase21_performance_indexes
|
||||||
@@ -880,6 +892,16 @@ sudo systemctl restart gunicorn
|
|||||||
# -d "token=YOUR_DIGEST_SECRET"
|
# -d "token=YOUR_DIGEST_SECRET"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### phase37_support_chat
|
||||||
|
|
||||||
|
Revision id `phase37_support_chat`. Creates `support_chat_sessions` + `support_chat_messages` so the customer AI support-chat is persisted (see §5 and §18 "Support Chat"). Table existence checks — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
**Deploy order for phases 24–32:**
|
**Deploy order for phases 24–32:**
|
||||||
```bash
|
```bash
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
@@ -1097,9 +1119,10 @@ A **PDF Summary** button was added to `reports/scorecard.html` alongside the exi
|
|||||||
|
|
||||||
`GET /support/chat` — customer only. Renders:
|
`GET /support/chat` — customer only. Renders:
|
||||||
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
|
- Greeting message with `current_user.display_name` (injected via `var userName = {{ current_user.display_name | tojson }}` — use `tojson` not inline interpolation to prevent XSS/quote breaks).
|
||||||
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag.
|
- FAQ quick-reply chips: text stored in `data-faq="..."` HTML attribute (HTML-escaped with `| e`), read in JS via `btn.dataset.faq`. **Never use `| tojson` in an `onclick=""` attribute** — it emits double-quoted JSON inside a double-quoted attribute, breaking HTML parsing and truncating the `<script>` tag. **The FAQ section stays visible for the whole session** (phase37) — it is NOT hidden after the first message, and chips are not disabled after clicking (reusable throughout).
|
||||||
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
|
- Chat history kept client-side in `let history = []`, sent with each AJAX `POST /support/chat/message`. Server caps at last 20 turns.
|
||||||
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown.
|
- **Persistence (phase37):** conversations are saved to `support_chat_sessions` / `support_chat_messages`. The page reloads the customer's most recent session into the window (continuity) unless `?new=1`. `let sessionId` (seeded from `chat_session_id`) is sent with each message and updated from the response; the server persists both turns. Header buttons: **History** (`/support/my-conversations`) and **New** (`/support/chat?new=1`). Staff can review any customer's chats read-only at `/support/admin/conversations`. Both detail views `{% include 'support/_transcript.html' %}`.
|
||||||
|
- If `GROQ_API_KEY` is absent, input is disabled and a fallback "Submit to Support" link is shown (the reply is still persisted).
|
||||||
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
|
- "Submit to Support" modal POSTs to `POST /support/tickets`; subject pre-filled from last user message in history.
|
||||||
|
|
||||||
### Inspection Execute Page — UX Patterns
|
### Inspection Execute Page — UX Patterns
|
||||||
|
|||||||
@@ -39,3 +39,54 @@ class SupportTicketReply(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
|
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI support-chat persistence (phase37) ─────────────────────────────────────
|
||||||
|
|
||||||
|
class SupportChatSession(db.Model):
|
||||||
|
"""One saved AI-chat conversation for a customer. Persisted so both the
|
||||||
|
customer and staff can reference past conversations and maintain continuity
|
||||||
|
(prior messages are reloaded into the chat window on return)."""
|
||||||
|
__tablename__ = 'support_chat_sessions'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False, index=True)
|
||||||
|
|
||||||
|
customer = db.relationship('User', foreign_keys=[customer_id])
|
||||||
|
messages = db.relationship(
|
||||||
|
'SupportChatMessage', backref='session',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
order_by='SupportChatMessage.created_at',
|
||||||
|
lazy='dynamic',
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_count(self):
|
||||||
|
return self.messages.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def preview(self):
|
||||||
|
"""First user message, for list views."""
|
||||||
|
first = self.messages.filter_by(role='user').first()
|
||||||
|
return first.content if first else '(no messages)'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SupportChatSession {self.id} customer={self.customer_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class SupportChatMessage(db.Model):
|
||||||
|
"""A single turn in a SupportChatSession. role = 'user' | 'assistant'."""
|
||||||
|
__tablename__ = 'support_chat_messages'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
session_id = db.Column(db.Integer, db.ForeignKey('support_chat_sessions.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
role = db.Column(db.String(16), nullable=False) # 'user' | 'assistant'
|
||||||
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<SupportChatMessage {self.id} session={self.session_id} role={self.role}>'
|
||||||
|
|||||||
+104
-15
@@ -6,7 +6,8 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.support import SupportTicket, SupportTicketReply
|
from app.models.support import (SupportTicket, SupportTicketReply,
|
||||||
|
SupportChatSession, SupportChatMessage)
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.facility import Facility
|
from app.models.facility import Facility
|
||||||
from app.utils.decorators import supervisor_required
|
from app.utils.decorators import supervisor_required
|
||||||
@@ -65,11 +66,29 @@ def chat():
|
|||||||
.filter(Facility.id.in_(cids), Facility.active == True)
|
.filter(Facility.id.in_(cids), Facility.active == True)
|
||||||
.order_by(Facility.name).all()) if cids else []
|
.order_by(Facility.name).all()) if cids else []
|
||||||
|
|
||||||
|
# Load the customer's most recent conversation so it continues on return.
|
||||||
|
# A ?new=1 param (New conversation button) starts a fresh, empty window.
|
||||||
|
start_new = request.args.get('new')
|
||||||
|
session = None
|
||||||
|
if not start_new:
|
||||||
|
session = (SupportChatSession.query
|
||||||
|
.filter_by(customer_id=current_user.id)
|
||||||
|
.order_by(SupportChatSession.updated_at.desc())
|
||||||
|
.first())
|
||||||
|
chat_history = []
|
||||||
|
if session:
|
||||||
|
chat_history = [
|
||||||
|
{'role': m.role, 'content': m.content}
|
||||||
|
for m in session.messages.order_by(SupportChatMessage.created_at.asc()).all()
|
||||||
|
]
|
||||||
|
|
||||||
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
|
||||||
return render_template('support/chat.html',
|
return render_template('support/chat.html',
|
||||||
faqs=FAQS,
|
faqs=FAQS,
|
||||||
facilities=facilities,
|
facilities=facilities,
|
||||||
groq_ready=groq_ready)
|
groq_ready=groq_ready,
|
||||||
|
chat_session_id=(session.id if session else None),
|
||||||
|
chat_history=chat_history)
|
||||||
|
|
||||||
|
|
||||||
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
|
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
|
||||||
@@ -80,20 +99,20 @@ def chat_message():
|
|||||||
if current_user.role != 'customer':
|
if current_user.role != 'customer':
|
||||||
return jsonify({'error': 'Forbidden'}), 403
|
return jsonify({'error': 'Forbidden'}), 403
|
||||||
|
|
||||||
api_key = os.environ.get('GROQ_API_KEY')
|
|
||||||
if not api_key:
|
|
||||||
return jsonify({'reply': (
|
|
||||||
"I'm sorry, the AI assistant isn't configured right now. "
|
|
||||||
"Please use the **Submit to Support** form to reach our team directly."
|
|
||||||
)})
|
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
history = data.get('messages', []) # list of {role, content} dicts
|
history = data.get('messages', []) # list of {role, content} dicts
|
||||||
user_message = data.get('message', '').strip()
|
user_message = data.get('message', '').strip()
|
||||||
|
session_id = data.get('session_id')
|
||||||
|
|
||||||
if not user_message:
|
if not user_message:
|
||||||
return jsonify({'error': 'Empty message'}), 400
|
return jsonify({'error': 'Empty message'}), 400
|
||||||
|
|
||||||
|
# ── Generate the reply ────────────────────────────────────────────────
|
||||||
|
api_key = os.environ.get('GROQ_API_KEY')
|
||||||
|
if not api_key:
|
||||||
|
reply = ("I'm sorry, the AI assistant isn't configured right now. "
|
||||||
|
"Please use the **Submit to Support** form to reach our team directly.")
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
from groq import Groq
|
from groq import Groq
|
||||||
client = Groq(api_key=api_key)
|
client = Groq(api_key=api_key)
|
||||||
@@ -113,14 +132,59 @@ def chat_message():
|
|||||||
temperature=0.5,
|
temperature=0.5,
|
||||||
)
|
)
|
||||||
reply = completion.choices[0].message.content.strip()
|
reply = completion.choices[0].message.content.strip()
|
||||||
return jsonify({'reply': reply})
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('SUPPORT | Groq error: %s', exc)
|
logger.error('SUPPORT | Groq error: %s', exc)
|
||||||
return jsonify({'reply': (
|
reply = ("I ran into a problem reaching the AI assistant. "
|
||||||
"I ran into a problem reaching the AI assistant. "
|
"Please try again, or use **Submit to Support** to contact our team.")
|
||||||
"Please try again, or use **Submit to Support** to contact our team."
|
|
||||||
)})
|
# ── Persist the turn (user message + assistant reply) ─────────────────
|
||||||
|
now = now_eastern()
|
||||||
|
session = None
|
||||||
|
if session_id:
|
||||||
|
session = db.session.get(SupportChatSession, session_id)
|
||||||
|
if session is not None and session.customer_id != current_user.id:
|
||||||
|
session = None # never write into someone else's session
|
||||||
|
if session is None:
|
||||||
|
session = SupportChatSession(customer_id=current_user.id,
|
||||||
|
created_at=now, updated_at=now)
|
||||||
|
db.session.add(session)
|
||||||
|
db.session.flush() # assign session.id
|
||||||
|
|
||||||
|
db.session.add(SupportChatMessage(session_id=session.id, role='user',
|
||||||
|
content=user_message, created_at=now))
|
||||||
|
db.session.add(SupportChatMessage(session_id=session.id, role='assistant',
|
||||||
|
content=reply, created_at=now))
|
||||||
|
session.updated_at = now
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'reply': reply, 'session_id': session.id})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Customer: saved chat conversations ────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/my-conversations')
|
||||||
|
@login_required
|
||||||
|
def my_conversations():
|
||||||
|
if current_user.role != 'customer':
|
||||||
|
abort(403)
|
||||||
|
sessions = (SupportChatSession.query
|
||||||
|
.filter_by(customer_id=current_user.id)
|
||||||
|
.order_by(SupportChatSession.updated_at.desc())
|
||||||
|
.all())
|
||||||
|
return render_template('support/my_conversations.html', sessions=sessions)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/my-conversations/<int:session_id>')
|
||||||
|
@login_required
|
||||||
|
def conversation_detail(session_id):
|
||||||
|
if current_user.role != 'customer':
|
||||||
|
abort(403)
|
||||||
|
session = db.session.get(SupportChatSession, session_id)
|
||||||
|
if session is None or session.customer_id != current_user.id:
|
||||||
|
abort(404)
|
||||||
|
messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all()
|
||||||
|
return render_template('support/conversation_detail.html',
|
||||||
|
session=session, messages=messages)
|
||||||
|
|
||||||
|
|
||||||
# ── Submit support ticket ─────────────────────────────────────────────────────
|
# ── Submit support ticket ─────────────────────────────────────────────────────
|
||||||
@@ -327,6 +391,31 @@ def admin_ticket_detail(ticket_id):
|
|||||||
replies=replies)
|
replies=replies)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin: view saved chat conversations (read-only) ──────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/admin/conversations')
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def admin_conversations():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
sessions = (SupportChatSession.query
|
||||||
|
.order_by(SupportChatSession.updated_at.desc())
|
||||||
|
.paginate(page=page, per_page=25, error_out=False))
|
||||||
|
return render_template('support/admin_conversations.html', sessions=sessions)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/conversations/<int:session_id>')
|
||||||
|
@login_required
|
||||||
|
@supervisor_required
|
||||||
|
def admin_conversation_detail(session_id):
|
||||||
|
session = db.session.get(SupportChatSession, session_id)
|
||||||
|
if session is None:
|
||||||
|
abort(404)
|
||||||
|
messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all()
|
||||||
|
return render_template('support/admin_conversation_detail.html',
|
||||||
|
session=session, messages=messages)
|
||||||
|
|
||||||
|
|
||||||
def _notify_customer_reply(ticket, reply):
|
def _notify_customer_reply(ticket, reply):
|
||||||
"""Create an in-app notification and send an email to the customer."""
|
"""Create an in-app notification and send an email to the customer."""
|
||||||
if not ticket.customer:
|
if not ticket.customer:
|
||||||
|
|||||||
@@ -180,6 +180,11 @@
|
|||||||
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
<i class="bi bi-chat-dots me-2"></i>Ask a Question
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('support.my_conversations') }}">
|
||||||
|
<i class="bi bi-clock-history me-2"></i>My Conversations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
|
||||||
<i class="bi bi-inbox me-2"></i>My Requests
|
<i class="bi bi-inbox me-2"></i>My Requests
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{# Read-only chat transcript. Expects `messages` (list of SupportChatMessage). #}
|
||||||
|
<style>
|
||||||
|
.cbubble { max-width:80%; padding:.6rem .9rem; border-radius:1rem; font-size:.9rem;
|
||||||
|
line-height:1.5; white-space:pre-wrap; word-break:break-word; }
|
||||||
|
.cbubble-user { background:#0d6efd; color:#fff; border-bottom-right-radius:.25rem; }
|
||||||
|
.cbubble-ai { background:#fff; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; }
|
||||||
|
</style>
|
||||||
|
<div class="p-3" style="background:#f8f9fa;">
|
||||||
|
{% for m in messages %}
|
||||||
|
<div class="d-flex mb-2 {{ 'justify-content-end' if m.role == 'user' else 'justify-content-start' }}">
|
||||||
|
<div class="cbubble {{ 'cbubble-user' if m.role == 'user' else 'cbubble-ai' }}">
|
||||||
|
{{ m.content }}
|
||||||
|
<div class="mt-1" style="font-size:.7rem;opacity:.6;">
|
||||||
|
{{ 'You' if m.role == 'user' else 'JQC Assistant' }} · {{ m.created_at.strftime('%b %d, %I:%M %p') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted text-center small">This conversation has no messages.</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Conversation #{{ session.id }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="bi bi-chat-square-text me-2 text-primary"></i>Conversation #{{ session.id }}</h4>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ session.customer.display_name if session.customer else 'Unknown customer' }}
|
||||||
|
· started {{ session.created_at.strftime('%b %d, %Y %I:%M %p') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>All Conversations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
{% include 'support/_transcript.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Support Conversations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-chat-left-dots me-2 text-primary"></i>Customer Chat Conversations</h4>
|
||||||
|
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-life-preserver me-1"></i>Support Tickets
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if sessions.items %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>First message</th>
|
||||||
|
<th class="text-center">Messages</th>
|
||||||
|
<th>Last activity</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ s.customer.display_name if s.customer else '—' }}</td>
|
||||||
|
<td class="text-muted small text-truncate" style="max-width:340px;">
|
||||||
|
{{ s.preview[:120] }}{% if s.preview|length > 120 %}…{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">{{ s.message_count }}</td>
|
||||||
|
<td class="small text-muted text-nowrap">{{ s.updated_at.strftime('%b %d, %Y %I:%M %p') }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ url_for('support.admin_conversation_detail', session_id=s.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i> View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-muted text-center">No chat conversations yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if sessions.pages > 1 %}
|
||||||
|
<nav class="mt-3"><ul class="pagination justify-content-center">
|
||||||
|
{% for p in range(1, sessions.pages + 1) %}
|
||||||
|
<li class="page-item {{ 'active' if p == sessions.page }}">
|
||||||
|
<a class="page-link" href="{{ url_for('support.admin_conversations', page=p) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul></nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
|
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
|
||||||
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
|
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-chat-left-dots me-1"></i>Chat Conversations
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Status filter tabs #}
|
{# Status filter tabs #}
|
||||||
|
|||||||
@@ -39,15 +39,25 @@
|
|||||||
<div class="col-lg-8">
|
<div class="col-lg-8">
|
||||||
|
|
||||||
{# ── Header ── #}
|
{# ── Header ── #}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
|
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
|
||||||
<small class="text-muted">Ask a question or browse common topics below</small>
|
<small class="text-muted">Ask a question or browse common topics below</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm"
|
||||||
|
title="View your saved conversations">
|
||||||
|
<i class="bi bi-clock-history me-1"></i>History
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('support.chat', new=1) }}" class="btn btn-outline-secondary btn-sm"
|
||||||
|
title="Start a fresh conversation">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>New
|
||||||
|
</a>
|
||||||
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
|
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
|
||||||
<i class="bi bi-envelope me-1"></i>Submit to Support
|
<i class="bi bi-envelope me-1"></i>Submit to Support
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ── Chat card ── #}
|
{# ── Chat card ── #}
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
@@ -153,17 +163,27 @@
|
|||||||
|
|
||||||
const chatWindow = document.getElementById('chat-window');
|
const chatWindow = document.getElementById('chat-window');
|
||||||
const chatInput = document.getElementById('chat-input');
|
const chatInput = document.getElementById('chat-input');
|
||||||
const faqSection = document.getElementById('faq-section');
|
|
||||||
const CSRF_TOKEN = '{{ csrf_token() }}';
|
const CSRF_TOKEN = '{{ csrf_token() }}';
|
||||||
|
|
||||||
// In-memory conversation history sent to the server with each message
|
// In-memory conversation history sent to the server with each message
|
||||||
let history = [];
|
let history = [];
|
||||||
|
// Server-side saved session this chat is being persisted to (null until first send)
|
||||||
|
let sessionId = {{ chat_session_id | tojson }};
|
||||||
|
const PRIOR = {{ chat_history | tojson }};
|
||||||
|
|
||||||
// ── Greeting on page load ──────────────────────────────────────────────
|
// ── Restore prior conversation, or greet on a fresh session ────────────
|
||||||
var userName = {{ current_user.display_name | tojson }};
|
var userName = {{ current_user.display_name | tojson }};
|
||||||
|
if (PRIOR && PRIOR.length) {
|
||||||
|
PRIOR.forEach(function (m) {
|
||||||
|
appendMessage(m.role === 'user' ? 'user' : 'ai', m.content);
|
||||||
|
history.push({ role: m.role, content: m.content });
|
||||||
|
});
|
||||||
|
appendSystem('Continuing your previous conversation. Click "New" above to start a fresh one.');
|
||||||
|
} else {
|
||||||
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
|
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
|
||||||
"I can help you with inspections, issues, reports, and more. " +
|
"I can help you with inspections, issues, reports, and more. " +
|
||||||
"Click a question below or type your own.");
|
"Click a question below or type your own.");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Append a message bubble ────────────────────────────────────────────
|
// ── Append a message bubble ────────────────────────────────────────────
|
||||||
function appendMessage(role, text) {
|
function appendMessage(role, text) {
|
||||||
@@ -202,8 +222,7 @@
|
|||||||
function sendMessage(text) {
|
function sendMessage(text) {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
|
|
||||||
// Hide FAQ chips after first interaction
|
// FAQ chips stay visible for the whole session (kept intentionally).
|
||||||
if (faqSection) faqSection.style.display = 'none';
|
|
||||||
|
|
||||||
appendMessage('user', text);
|
appendMessage('user', text);
|
||||||
history.push({ role: 'user', content: text });
|
history.push({ role: 'user', content: text });
|
||||||
@@ -219,11 +238,12 @@
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRFToken': CSRF_TOKEN,
|
'X-CSRFToken': CSRF_TOKEN,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ message: text, messages: history }),
|
body: JSON.stringify({ message: text, messages: history, session_id: sessionId }),
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
hideTyping();
|
hideTyping();
|
||||||
|
if (data.session_id) { sessionId = data.session_id; }
|
||||||
const reply = data.reply || 'Sorry, I could not process your request.';
|
const reply = data.reply || 'Sorry, I could not process your request.';
|
||||||
appendMessage('ai', reply);
|
appendMessage('ai', reply);
|
||||||
history.push({ role: 'assistant', content: reply });
|
history.push({ role: 'assistant', content: reply });
|
||||||
@@ -252,7 +272,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.sendFaq = function (btn) {
|
window.sendFaq = function (btn) {
|
||||||
btn.disabled = true;
|
// Chips remain enabled/visible so they can be used throughout the session.
|
||||||
sendMessage(btn.dataset.faq);
|
sendMessage(btn.dataset.faq);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Conversation{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="bi bi-chat-square-text me-2 text-primary"></i>Conversation</h4>
|
||||||
|
<small class="text-muted">{{ session.created_at.strftime('%b %d, %Y %I:%M %p') }}</small>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>All Conversations
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
{% include 'support/_transcript.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted small mt-2 text-center">
|
||||||
|
Need more help? <a href="{{ url_for('support.chat') }}">Return to chat</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}My Conversations{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-clock-history me-2 text-primary"></i>My Support Conversations</h4>
|
||||||
|
<a href="{{ url_for('support.chat') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-chat-dots me-1"></i>Back to Chat
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if sessions %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for s in sessions %}
|
||||||
|
<a href="{{ url_for('support.conversation_detail', session_id=s.id) }}"
|
||||||
|
class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="me-3 text-truncate">
|
||||||
|
<div class="fw-semibold text-truncate">{{ s.preview[:100] }}{% if s.preview|length > 100 %}…{% endif %}</div>
|
||||||
|
<small class="text-muted">{{ s.message_count }} message{{ 's' if s.message_count != 1 }}</small>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap">{{ s.updated_at.strftime('%b %d, %Y %I:%M %p') }}</small>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-muted text-center">
|
||||||
|
You have no saved conversations yet.
|
||||||
|
<a href="{{ url_for('support.chat') }}">Start a chat</a>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""phase37 — persist AI support-chat conversations
|
||||||
|
|
||||||
|
Creates support_chat_sessions + support_chat_messages so customer AI-chat
|
||||||
|
conversations are saved for later reference and continuity.
|
||||||
|
|
||||||
|
Uses table existence checks — safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
revision = 'phase37_support_chat'
|
||||||
|
down_revision = 'phase36_scheduled_insp'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table):
|
||||||
|
return conn.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||||
|
), {"t": table}).scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _table_exists(bind, 'support_chat_sessions'):
|
||||||
|
op.create_table(
|
||||||
|
'support_chat_sessions',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('customer_id', sa.Integer,
|
||||||
|
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('ix_scs_customer', 'support_chat_sessions', ['customer_id'])
|
||||||
|
op.create_index('ix_scs_updated', 'support_chat_sessions', ['updated_at'])
|
||||||
|
|
||||||
|
if not _table_exists(bind, 'support_chat_messages'):
|
||||||
|
op.create_table(
|
||||||
|
'support_chat_messages',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('session_id', sa.Integer,
|
||||||
|
sa.ForeignKey('support_chat_sessions.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('role', sa.String(16), nullable=False),
|
||||||
|
sa.Column('content', sa.Text, nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('ix_scm_session', 'support_chat_messages', ['session_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, 'support_chat_messages'):
|
||||||
|
op.drop_table('support_chat_messages')
|
||||||
|
if _table_exists(bind, 'support_chat_sessions'):
|
||||||
|
op.drop_table('support_chat_sessions')
|
||||||
Reference in New Issue
Block a user