Comp_App / app.py
Aigenthix's picture
Update app.py
c763bc7 verified
Raw
History Blame Contribute Delete
59.4 kB
import os
import json
import uuid
import datetime
import hashlib
import secrets
from pathlib import Path
from fastapi import FastAPI, HTTPException, Header, Response, Request
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel
from groq import Groq
from typing import List, Optional
app = FastAPI()
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
# ── Persistent storage ───────────────────────────────────────────────────────
_preferred = Path("/data/pharma_chat")
_fallback = Path(os.environ.get("HOME", "/home/user")) / "pharma_chat"
try:
_preferred.mkdir(parents=True, exist_ok=True)
DATA_DIR = _preferred
except PermissionError:
_fallback.mkdir(parents=True, exist_ok=True)
DATA_DIR = _fallback
THREADS_FILE = DATA_DIR / "threads.json"
SESSIONS_FILE = DATA_DIR / "sessions.json"
def load_threads() -> dict:
if THREADS_FILE.exists():
try:
return json.loads(THREADS_FILE.read_text())
except Exception:
return {}
return {}
def save_threads(threads: dict):
THREADS_FILE.write_text(json.dumps(threads, indent=2))
def load_sessions() -> dict:
if SESSIONS_FILE.exists():
try:
return json.loads(SESSIONS_FILE.read_text())
except Exception:
return {}
return {}
def save_sessions(sessions: dict):
SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
# ── Auth ─────────────────────────────────────────────────────────────────────
# Dummy credentials — change these for production
USERS = {
"admin": {"password": "pharma123", "role": "Admin", "name": "Admin User"},
"auditor": {"password": "audit2024", "role": "Auditor", "name": "Sarah Chen"},
"reviewer": {"password": "review99", "role": "Reviewer", "name": "James Patel"},
"analyst": {"password": "analyst01", "role": "Analyst", "name": "Maria Lopez"},
}
SESSION_TTL_HOURS = 8
def create_session(username: str) -> str:
token = secrets.token_hex(32)
sessions = load_sessions()
sessions[token] = {
"username": username,
"created_at": datetime.datetime.utcnow().isoformat(),
"expires_at": (datetime.datetime.utcnow() + datetime.timedelta(hours=SESSION_TTL_HOURS)).isoformat(),
}
save_sessions(sessions)
return token
def get_session(token: str) -> Optional[dict]:
if not token:
return None
sessions = load_sessions()
s = sessions.get(token)
if not s:
return None
if datetime.datetime.utcnow() > datetime.datetime.fromisoformat(s["expires_at"]):
del sessions[token]
save_sessions(sessions)
return None
return s
def require_auth(authorization: Optional[str] = Header(default=None)):
token = None
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
s = get_session(token)
if not s:
raise HTTPException(status_code=401, detail="Not authenticated")
return s
# ── Models ───────────────────────────────────────────────────────────────────
MODELS = {
"llama-3.1-8b-instant": {"name": "Llama 3.1 8B", "description": "Fast responses", "icon": "⚡"},
"llama-3.3-70b-versatile": {"name": "Llama 3.3 70B", "description": "Deep analysis", "icon": "🧠"},
}
SYSTEM_PROMPT = """You are PharmaComply AI, an expert pharmaceutical compliance assistant with deep knowledge across:
REGULATORY FRAMEWORKS:
- FDA (21 CFR Parts 11, 210, 211, 312, 314, 820) — cGMP, IND/NDA/ANDA submissions, 510(k)
- EMA guidelines, ICH Q10 Pharmaceutical Quality System, ICH E6 GCP
- EU GMP Annex 1-21, EudraLex Volume 4
- WHO GMP guidelines, PIC/S standards
PHARMACOVIGILANCE (PV):
- ICH E2A-E2F guidelines, EMA GVP modules I-XVI
- FAERS/EudraVigilance reporting, MedWatch, PSUR/PBRER preparation
- Signal detection, risk management (REMS/RMP), aggregate safety reporting
- Serious Adverse Event (SAE) reporting timelines (7-day, 15-day, expedited)
CLINICAL TRIALS / GCP:
- ICH E6(R2/R3) Good Clinical Practice
- Protocol deviations, CAPA management, audit findings
- Informed consent regulations, IRB/IEC requirements
- eCTD format, TMF structure (DIA Reference Model)
QUALITY SYSTEMS:
- CAPA (Corrective and Preventive Action) workflows
- Deviation management, OOS/OOT investigations
- Change control, validation (CSV/computerized systems, 21 CFR Part 11)
- Batch record review, release procedures
REPORTING:
When asked to generate a report, produce structured output with:
- Document header (title, date, classification)
- Executive summary
- Regulatory basis / applicable guidelines
- Findings with risk classification (Critical / Major / Minor / Observation)
- CAPA recommendations with timelines
- Conclusion and sign-off section
COMPLIANCE RULES:
- Always cite specific regulation section numbers (e.g., 21 CFR 211.68, ICH Q10 Section 3.2)
- Flag Critical findings that require immediate action
- Distinguish between regulatory requirements and best practices
- Never provide legal advice; recommend qualified regulatory counsel for legal matters
- Apply ALCOA+ principles throughout
Maintain professional, precise language appropriate for regulatory submissions and audit documentation."""
# ── Pydantic models ──────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str
password: str
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
thread_id: str
message: str
model: str = "llama-3.1-8b-instant"
class NewThreadRequest(BaseModel):
title: Optional[str] = None
class RenameRequest(BaseModel):
title: str
class ReportRequest(BaseModel):
thread_id: str
report_type: str
model: str = "llama-3.3-70b-versatile"
# ── Auth endpoints ───────────────────────────────────────────────────────────
@app.post("/api/login")
def login(req: LoginRequest):
user = USERS.get(req.username)
if not user or user["password"] != req.password:
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_session(req.username)
return {"ok": True, "token": token, "name": user["name"], "role": user["role"], "username": req.username}
@app.post("/api/logout")
def logout(authorization: Optional[str] = Header(default=None)):
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
sessions = load_sessions()
sessions.pop(token, None)
save_sessions(sessions)
return {"ok": True}
@app.get("/api/me")
def me(authorization: Optional[str] = Header(default=None)):
token = None
if authorization and authorization.startswith("Bearer "):
token = authorization[7:]
s = get_session(token)
if not s:
raise HTTPException(status_code=401, detail="Not authenticated")
u = USERS[s["username"]]
return {"username": s["username"], "name": u["name"], "role": u["role"]}
# ── Thread endpoints ─────────────────────────────────────────────────────────
@app.get("/api/threads")
def get_threads(authorization: Optional[str] = Header(default=None)):
require_auth(authorization)
threads = load_threads()
result = [
{"id": tid, "title": t.get("title","Untitled"), "created_at": t.get("created_at",""),
"updated_at": t.get("updated_at",""), "message_count": len(t.get("messages",[]))}
for tid, t in threads.items()
]
result.sort(key=lambda x: x["updated_at"], reverse=True)
return result
@app.post("/api/threads")
def create_thread(req: NewThreadRequest, authorization: Optional[str] = Header(default=None)):
s = require_auth(authorization)
threads = load_threads()
tid = str(uuid.uuid4())
now = datetime.datetime.utcnow().isoformat()
threads[tid] = {"id": tid, "title": req.title or "New Conversation",
"created_at": now, "updated_at": now,
"created_by": s["username"], "messages": []}
save_threads(threads)
return threads[tid]
@app.get("/api/threads/{thread_id}")
def get_thread(thread_id: str, authorization: Optional[str] = Header(default=None)):
require_auth(authorization)
threads = load_threads()
if thread_id not in threads:
raise HTTPException(status_code=404, detail="Thread not found")
return threads[thread_id]
@app.patch("/api/threads/{thread_id}")
def rename_thread(thread_id: str, req: RenameRequest, authorization: Optional[str] = Header(default=None)):
require_auth(authorization)
threads = load_threads()
if thread_id not in threads:
raise HTTPException(status_code=404, detail="Thread not found")
threads[thread_id]["title"] = req.title
threads[thread_id]["updated_at"] = datetime.datetime.utcnow().isoformat()
save_threads(threads)
return {"ok": True}
@app.delete("/api/threads/{thread_id}")
def delete_thread(thread_id: str, authorization: Optional[str] = Header(default=None)):
require_auth(authorization)
threads = load_threads()
if thread_id not in threads:
raise HTTPException(status_code=404, detail="Thread not found")
del threads[thread_id]
save_threads(threads)
return {"ok": True}
# ── Chat endpoint ────────────────────────────────────────────────────────────
@app.post("/api/chat")
def chat(req: ChatRequest, authorization: Optional[str] = Header(default=None)):
require_auth(authorization)
threads = load_threads()
if req.thread_id not in threads:
raise HTTPException(status_code=404, detail="Thread not found")
thread = threads[req.thread_id]
thread["messages"].append({"role": "user", "content": req.message})
history = [{"role": m["role"], "content": m["content"]} for m in thread["messages"]]
full_response = []
def generate():
stream = client.chat.completions.create(
model=req.model,
messages=[{"role": "system", "content": SYSTEM_PROMPT}] + history,
stream=True, max_tokens=4096,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
full_response.append(delta.content)
yield f"data: {json.dumps({'content': delta.content})}\n\n"
assistant_text = "".join(full_response)
thread["messages"].append({"role": "assistant", "content": assistant_text})
thread["updated_at"] = datetime.datetime.utcnow().isoformat()
if len(thread["messages"]) == 2 and thread["title"] == "New Conversation":
thread["title"] = req.message[:50] + ("…" if len(req.message) > 50 else "")
threads[req.thread_id] = thread
save_threads(threads)
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
)
# ── Report endpoint ──────────────────────────────────────────────────────────
@app.post("/api/report")
def generate_report(req: ReportRequest, authorization: Optional[str] = Header(default=None)):
require_auth(authorization)
threads = load_threads()
if req.thread_id not in threads:
raise HTTPException(status_code=404, detail="Thread not found")
thread = threads[req.thread_id]
history = [{"role": m["role"], "content": m["content"]} for m in thread["messages"]]
report_prompts = {
"compliance_summary": "Generate a formal pharmaceutical compliance summary report based on this conversation. Include: Executive Summary, Regulatory Basis, Key Findings with risk classification (Critical/Major/Minor/Observation), CAPA Recommendations with timelines, and Conclusion. Format as a professional regulatory document.",
"deviation_report": "Generate a formal deviation report based on this conversation. Include: Deviation Description, Root Cause Analysis, Immediate Containment Actions, Regulatory Impact Assessment, CAPA Plan with owners and due dates. Use GMP deviation report format.",
"audit_findings": "Generate a formal audit findings report based on this conversation. Include: Audit Scope, Applicable Regulations, Findings Table (Finding #, Description, Classification, Reference, CAPA), Risk Summary, and Closing Statement.",
"pv_assessment": "Generate a pharmacovigilance assessment report. Include: Safety Signal Summary, ICH E2A/E2E classification, Reporting Obligations (7-day/15-day/PSUR), Risk-Benefit Assessment, and Regulatory Action Required.",
"regulatory_briefing": "Generate a regulatory affairs briefing document. Include: Regulatory Strategy Overview, Applicable Guidelines, Submission Requirements, Timeline, Open Issues, and Recommendations.",
}
prompt = report_prompts.get(req.report_type, report_prompts["compliance_summary"])
def generate():
stream = client.chat.completions.create(
model=req.model,
messages=[{"role": "system", "content": SYSTEM_PROMPT}, *history,
{"role": "user", "content": prompt}],
stream=True, max_tokens=4096,
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield f"data: {json.dumps({'content': delta.content})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
)
# ── Frontend ─────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def root():
return HTMLResponse(content=HTML)
HTML = r"""<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>PharmaComply AI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
/* ── THEME TOKENS ── */
html[data-theme="dark"]{
--bg:#08090c;--surface:#10121a;--surface-2:#181c28;--surface-3:#1e2330;
--border:#252a38;--border-2:#2e3548;
--accent:#2563eb;--accent-light:#3b82f6;--accent-glow:rgba(37,99,235,0.15);
--green:#10b981;--amber:#f59e0b;--red:#ef4444;--purple:#8b5cf6;
--text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#475569;
--user-bubble:#0f1f3d;--user-border:#1e3a5f;
--card-shadow:0 4px 24px rgba(0,0,0,0.4);
--login-bg:linear-gradient(135deg,#0a0e1a 0%,#0f1628 50%,#0a0e1a 100%);
}
html[data-theme="light"]{
--bg:#f0f4f8;--surface:#ffffff;--surface-2:#f8fafc;--surface-3:#f1f5f9;
--border:#e2e8f0;--border-2:#cbd5e1;
--accent:#2563eb;--accent-light:#3b82f6;--accent-glow:rgba(37,99,235,0.08);
--green:#059669;--amber:#d97706;--red:#dc2626;--purple:#7c3aed;
--text:#0f172a;--text-muted:#475569;--text-dim:#94a3b8;
--user-bubble:#eff6ff;--user-border:#bfdbfe;
--card-shadow:0 4px 24px rgba(0,0,0,0.08);
--login-bg:linear-gradient(135deg,#e0e7ff 0%,#f0f9ff 50%,#e0e7ff 100%);
}
html,body{height:100%;overflow:hidden}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6;transition:background .2s,color .2s}
/* ── LOGIN SCREEN ── */
#login-screen{
position:fixed;inset:0;z-index:1000;
background:var(--login-bg);
display:flex;align-items:center;justify-content:center;
transition:background .2s;
}
#login-screen.hidden{display:none}
.login-card{
background:var(--surface);border:1px solid var(--border);border-radius:20px;
padding:40px 36px;width:100%;max-width:400px;
box-shadow:var(--card-shadow);
}
.login-logo{text-align:center;margin-bottom:28px}
.login-logo-icon{width:64px;height:64px;background:linear-gradient(135deg,#1d4ed8,#7c3aed);border-radius:18px;display:inline-grid;place-items:center;font-size:32px;margin-bottom:14px;box-shadow:0 8px 32px rgba(37,99,235,.3)}
.login-logo h1{font-size:22px;font-weight:700;letter-spacing:-.4px}
.login-logo h1 span{color:#60a5fa}
.login-logo p{font-size:12px;color:var(--text-muted);margin-top:4px}
.login-form{display:flex;flex-direction:column;gap:14px}
.form-group{display:flex;flex-direction:column;gap:5px}
.form-label{font-size:12px;font-weight:600;color:var(--text-muted);letter-spacing:.3px}
.form-input{
padding:10px 13px;border-radius:9px;border:1px solid var(--border);
background:var(--surface-2);color:var(--text);font-family:inherit;font-size:13.5px;
outline:none;transition:border-color .15s,box-shadow .15s;
}
.form-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
.login-btn{
padding:11px;border-radius:9px;border:none;
background:var(--accent);color:#fff;font-size:14px;font-weight:600;
font-family:inherit;cursor:pointer;transition:all .15s;margin-top:4px;
}
.login-btn:hover{background:var(--accent-light);transform:translateY(-1px)}
.login-btn:active{transform:none}
.login-error{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:var(--red);border-radius:8px;padding:9px 12px;font-size:12px;display:none}
.login-error.show{display:block}
.login-creds{margin-top:16px;background:var(--surface-2);border:1px solid var(--border);border-radius:10px;padding:14px}
.login-creds-title{font-size:11px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.7px;margin-bottom:10px}
.cred-table{width:100%;border-collapse:collapse;font-size:11px}
.cred-table th{text-align:left;color:var(--text-dim);padding:3px 6px;font-weight:500}
.cred-table td{padding:3px 6px;color:var(--text-muted)}
.cred-table td code{background:var(--surface-3);border:1px solid var(--border);padding:1px 5px;border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent-light);cursor:pointer}
.theme-toggle-login{position:absolute;top:16px;right:16px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:7px 11px;cursor:pointer;color:var(--text-muted);font-size:13px;transition:all .15s}
.theme-toggle-login:hover{background:var(--surface-2);color:var(--text)}
/* ── APP LAYOUT ── */
#app{display:flex;height:100vh;overflow:hidden}
#app.hidden{display:none}
/* ── SIDEBAR ── */
.sidebar{width:280px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;transition:background .2s}
.sidebar-top{padding:14px 12px 12px;border-bottom:1px solid var(--border)}
.brand{display:flex;align-items:center;gap:9px;padding:0 4px;margin-bottom:12px}
.brand-icon{width:32px;height:32px;background:linear-gradient(135deg,#1d4ed8,#7c3aed);border-radius:8px;display:grid;place-items:center;flex-shrink:0;font-size:16px}
.brand-name{font-size:14px;font-weight:700;letter-spacing:-.3px}
.brand-name span{color:#60a5fa}
.brand-badge{font-size:9px;font-weight:700;background:var(--accent-glow);color:#60a5fa;border:1px solid var(--accent);border-radius:4px;padding:1px 5px;letter-spacing:.5px;text-transform:uppercase;margin-left:auto;flex-shrink:0}
.new-btn{width:100%;padding:8px 12px;background:var(--accent-glow);border:1px solid var(--accent);border-radius:8px;color:#60a5fa;font-size:12.5px;font-weight:500;cursor:pointer;display:flex;align-items:center;gap:7px;transition:all .15s;font-family:inherit}
.new-btn:hover{background:rgba(37,99,235,.28)}
.model-wrap{padding:10px 12px;border-bottom:1px solid var(--border)}
.section-label{font-size:10px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
.model-pills{display:flex;gap:5px}
.model-pill{flex:1;padding:5px 7px;border-radius:7px;border:1px solid var(--border);background:none;color:var(--text-muted);font-size:11px;font-weight:500;cursor:pointer;text-align:center;transition:all .15s;font-family:inherit}
.model-pill:hover{background:var(--surface-2)}
.model-pill.active{background:var(--accent-glow);border-color:var(--accent);color:#60a5fa}
.threads-wrap{flex:1;overflow-y:auto;padding:10px 8px}
.threads-empty{padding:24px 8px;color:var(--text-dim);font-size:12px;text-align:center;line-height:2}
.thread-item{padding:8px 10px;border-radius:9px;cursor:pointer;border:1px solid transparent;margin-bottom:3px;transition:all .15s;position:relative}
.thread-item:hover{background:var(--surface-2)}
.thread-item.active{background:var(--accent-glow);border-color:var(--accent)}
.thread-item-title{font-size:12px;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:48px}
.thread-item.active .thread-item-title{color:#90c4ff}
.thread-item-meta{font-size:10px;color:var(--text-dim);margin-top:2px}
.thread-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:none;gap:2px}
.thread-item:hover .thread-actions,.thread-item.active .thread-actions{display:flex}
.thread-action-btn{width:22px;height:22px;border-radius:5px;border:none;background:var(--surface-3);color:var(--text-muted);cursor:pointer;display:grid;place-items:center;font-size:10px;transition:all .15s}
.thread-action-btn:hover{background:var(--border-2);color:var(--text)}
.thread-action-btn.del:hover{background:rgba(239,68,68,.15);color:var(--red)}
.thread-rename-input{width:100%;background:var(--surface-3);border:1px solid var(--accent);border-radius:6px;color:var(--text);font-size:12px;font-family:inherit;padding:3px 7px;outline:none}
.sidebar-footer{padding:10px 12px;border-top:1px solid var(--border)}
.user-card{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;background:var(--surface-2);border:1px solid var(--border);margin-bottom:8px}
.user-avatar{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--purple));display:grid;place-items:center;font-size:12px;font-weight:700;color:#fff;flex-shrink:0}
.user-info{min-width:0}
.user-name{font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-role{font-size:10px;color:var(--text-muted)}
.sidebar-actions{display:flex;gap:6px}
.sidebar-action-btn{flex:1;padding:6px;border-radius:7px;border:1px solid var(--border);background:none;color:var(--text-muted);font-size:11px;font-family:inherit;cursor:pointer;transition:all .15s;display:flex;align-items:center;justify-content:center;gap:4px}
.sidebar-action-btn:hover{background:var(--surface-2);color:var(--text)}
.sidebar-action-btn.logout:hover{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.3);color:var(--red)}
/* ── MAIN ── */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
.topbar{padding:11px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;background:var(--surface);flex-shrink:0;gap:12px;transition:background .2s}
.topbar-left{display:flex;align-items:center;gap:9px;min-width:0}
.topbar-dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite;flex-shrink:0}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.topbar-title{font-size:13px;font-weight:500;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.topbar-right{display:flex;gap:6px;flex-shrink:0;align-items:center}
.top-btn{padding:6px 11px;border-radius:7px;border:1px solid var(--border);background:none;color:var(--text-muted);font-size:12px;font-family:inherit;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:4px;white-space:nowrap}
.top-btn:hover{background:var(--surface-2);color:var(--text)}
.top-btn.primary{background:var(--accent-glow);border-color:var(--accent);color:#60a5fa}
.top-btn.primary:hover{background:rgba(37,99,235,.28)}
.theme-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;font-size:15px}
.report-dropdown{position:relative}
.report-menu{position:absolute;right:0;top:calc(100% + 6px);background:var(--surface-2);border:1px solid var(--border-2);border-radius:10px;padding:5px;min-width:210px;z-index:100;display:none;box-shadow:var(--card-shadow)}
.report-menu.open{display:block}
.report-menu-item{padding:8px 11px;border-radius:7px;cursor:pointer;font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:8px;transition:all .15s}
.report-menu-item:hover{background:var(--surface-3);color:var(--text)}
/* ── CHAT ── */
.chat-window{flex:1;overflow-y:auto;padding:24px 0;scroll-behavior:smooth}
.chat-inner{max-width:780px;margin:0 auto;padding:0 20px}
.welcome{text-align:center;padding:44px 20px 28px;display:flex;flex-direction:column;align-items:center;gap:12px}
.welcome-icon{width:68px;height:68px;background:linear-gradient(135deg,#1d4ed8,#7c3aed);border-radius:18px;display:grid;place-items:center;font-size:34px;box-shadow:0 0 40px rgba(37,99,235,.25)}
.welcome h1{font-size:22px;font-weight:700;letter-spacing:-.4px}
.welcome h1 span{color:#60a5fa}
.welcome p{color:var(--text-muted);font-size:13px;max-width:400px;line-height:1.7}
.compliance-tags{display:flex;flex-wrap:wrap;gap:5px;justify-content:center}
.ctag{padding:3px 9px;border-radius:20px;font-size:10px;font-weight:600;border:1px solid}
.ctag.fda{color:#60a5fa;border-color:rgba(96,165,250,.3);background:rgba(37,99,235,.1)}
.ctag.ema{color:#a78bfa;border-color:rgba(167,139,250,.3);background:rgba(139,92,246,.1)}
.ctag.ich{color:#34d399;border-color:rgba(52,211,153,.3);background:rgba(16,185,129,.1)}
.ctag.gcp{color:#fbbf24;border-color:rgba(251,191,36,.3);background:rgba(245,158,11,.1)}
.ctag.pv{color:#f87171;border-color:rgba(248,113,113,.3);background:rgba(239,68,68,.1)}
.ctag.gmp{color:#e879f9;border-color:rgba(232,121,249,.3);background:rgba(217,70,239,.1)}
.starter-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:4px;width:100%;max-width:540px}
.starter-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;text-align:left;transition:all .15s;font-family:inherit;color:var(--text)}
.starter-card:hover{border-color:var(--accent);background:var(--accent-glow)}
.starter-card .sc-icon{font-size:16px;margin-bottom:4px;display:block}
.starter-card .sc-title{font-size:11.5px;font-weight:600;margin-bottom:2px}
.starter-card .sc-text{font-size:11px;color:var(--text-muted)}
.message{margin-bottom:22px;display:flex;flex-direction:column}
.message.user{align-items:flex-end}
.message.assistant{align-items:flex-start}
.msg-header{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.7px;margin-bottom:5px;display:flex;align-items:center;gap:6px}
.message.user .msg-header{color:#60a5fa}
.message.assistant .msg-header{color:var(--text-dim)}
.msg-avatar{width:18px;height:18px;border-radius:4px;display:grid;place-items:center;font-size:9px}
.message.assistant .msg-avatar{background:linear-gradient(135deg,#1d4ed8,#7c3aed)}
.message.user .msg-avatar{background:#1e3a5f}
.msg-bubble{padding:12px 15px;border-radius:12px;line-height:1.75;font-size:13.5px}
.message.user .msg-bubble{background:var(--user-bubble);border:1px solid var(--user-border);border-radius:12px 12px 4px 12px;max-width:80%}
.message.assistant .msg-bubble{background:var(--surface);border:1px solid var(--border);border-radius:12px 12px 12px 4px;width:100%}
.msg-bubble code{font-family:'JetBrains Mono',monospace;background:rgba(37,99,235,.1);border:1px solid rgba(37,99,235,.2);padding:2px 5px;border-radius:4px;font-size:11.5px;color:#93c5fd}
.msg-bubble pre{background:var(--surface-3);border:1px solid var(--border);border-radius:8px;padding:13px;overflow-x:auto;margin:9px 0}
.msg-bubble pre code{background:none;border:none;padding:0;font-size:12px;color:var(--text)}
.msg-bubble p{margin-bottom:8px}
.msg-bubble p:last-child{margin-bottom:0}
.msg-bubble ul,.msg-bubble ol{padding-left:18px;margin:7px 0}
.msg-bubble li{margin-bottom:3px}
.msg-bubble strong{color:var(--text);font-weight:600}
.msg-bubble h1,.msg-bubble h2,.msg-bubble h3{font-weight:700;margin:12px 0 6px}
.msg-bubble h1{font-size:15px;border-bottom:1px solid var(--border);padding-bottom:5px}
.msg-bubble h2{font-size:13.5px;color:#60a5fa}
.msg-bubble h3{font-size:13px}
.msg-bubble blockquote{border-left:3px solid var(--accent);padding-left:11px;color:var(--text-muted);margin:7px 0}
.msg-bubble table{width:100%;border-collapse:collapse;margin:9px 0;font-size:12px}
.msg-bubble th{background:var(--surface-2);padding:7px 9px;text-align:left;border:1px solid var(--border);font-weight:600}
.msg-bubble td{padding:6px 9px;border:1px solid var(--border);color:var(--text-muted)}
.msg-bubble tr:nth-child(even) td{background:var(--surface-2)}
.cursor{display:inline-block;width:2px;height:13px;background:#60a5fa;vertical-align:middle;margin-left:2px;animation:blink 1s infinite;border-radius:1px}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
/* ── REPORT PANEL ── */
.report-panel{background:var(--surface-2);border-top:1px solid var(--border);padding:13px 20px;flex-shrink:0;display:none;transition:background .2s}
.report-panel.visible{display:block}
.report-panel-inner{max-width:780px;margin:0 auto}
.report-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px}
.report-title-label{font-size:12px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px}
.report-close{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:15px}
.report-content{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:15px;max-height:300px;overflow-y:auto;font-size:12.5px;line-height:1.8;color:var(--text-muted)}
.report-content h1,.report-content h2,.report-content h3{color:var(--text);font-weight:700;margin:11px 0 5px}
.report-content h1{font-size:14px;border-bottom:1px solid var(--border);padding-bottom:5px}
.report-content h2{font-size:13px;color:#60a5fa}
.report-content strong{color:var(--text)}
.report-content ul,.report-content ol{padding-left:17px;margin:5px 0}
.report-actions{display:flex;gap:7px;margin-top:9px}
.report-action-btn{padding:6px 13px;border-radius:7px;border:1px solid var(--border);background:none;color:var(--text-muted);font-size:11px;font-family:inherit;cursor:pointer;transition:all .15s}
.report-action-btn:hover{background:var(--surface-3);color:var(--text)}
.report-action-btn.primary{background:var(--accent-glow);border-color:var(--accent);color:#60a5fa}
/* ── INPUT ── */
.input-area{padding:12px 20px 15px;background:var(--bg);border-top:1px solid var(--border);flex-shrink:0;transition:background .2s}
.input-inner{max-width:780px;margin:0 auto}
.input-box{background:var(--surface);border:1px solid var(--border);border-radius:13px;padding:11px 13px;display:flex;align-items:flex-end;gap:9px;transition:border-color .15s}
.input-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
#user-input{flex:1;background:none;border:none;color:var(--text);font-family:'Inter',sans-serif;font-size:13.5px;line-height:1.6;resize:none;outline:none;max-height:160px;min-height:22px}
#user-input::placeholder{color:var(--text-dim)}
.send-btn{width:30px;height:30px;background:var(--accent);border:none;border-radius:8px;color:#fff;cursor:pointer;display:grid;place-items:center;flex-shrink:0;transition:all .15s}
.send-btn:hover{background:var(--accent-light);transform:scale(1.05)}
.send-btn:disabled{background:var(--border);cursor:not-allowed;transform:none}
.input-footer{display:flex;justify-content:center;margin-top:6px;font-size:10px;color:var(--text-dim);gap:10px}
::-webkit-scrollbar{width:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
@media(max-width:640px){.sidebar{display:none}.starter-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<!-- ══════════════════════ LOGIN SCREEN ══════════════════════ -->
<div id="login-screen">
<button class="theme-toggle-login" onclick="toggleTheme()" title="Toggle theme" id="login-theme-btn">🌙</button>
<div class="login-card">
<div class="login-logo">
<div class="login-logo-icon">⚕</div>
<h1>Pharma<span>Comply</span> AI</h1>
<p>Pharmaceutical Regulatory Compliance Platform</p>
</div>
<div class="login-form">
<div class="form-group">
<label class="form-label">USERNAME</label>
<input class="form-input" type="text" id="login-user" placeholder="Enter username" autocomplete="username" onkeydown="loginKey(event)"/>
</div>
<div class="form-group">
<label class="form-label">PASSWORD</label>
<input class="form-input" type="password" id="login-pass" placeholder="Enter password" autocomplete="current-password" onkeydown="loginKey(event)"/>
</div>
<div class="login-error" id="login-error">Invalid username or password. Please try again.</div>
<button class="login-btn" onclick="doLogin()" id="login-btn">Sign In</button>
</div>
<div class="login-creds">
<div class="login-creds-title">Demo Credentials</div>
<table class="cred-table">
<tr><th>Username</th><th>Password</th><th>Role</th></tr>
<tr><td><code onclick="fillCred('admin','pharma123')">admin</code></td><td><code onclick="fillCred('admin','pharma123')">pharma123</code></td><td>Admin</td></tr>
<tr><td><code onclick="fillCred('auditor','audit2024')">auditor</code></td><td><code onclick="fillCred('auditor','audit2024')">audit2024</code></td><td>Auditor</td></tr>
<tr><td><code onclick="fillCred('reviewer','review99')">reviewer</code></td><td><code onclick="fillCred('reviewer','review99')">review99</code></td><td>Reviewer</td></tr>
<tr><td><code onclick="fillCred('analyst','analyst01')">analyst</code></td><td><code onclick="fillCred('analyst','analyst01')">analyst01</code></td><td>Analyst</td></tr>
</table>
<div style="font-size:10px;color:var(--text-dim);margin-top:8px">💡 Click any credential to auto-fill</div>
</div>
</div>
</div>
<!-- ══════════════════════ MAIN APP ══════════════════════ -->
<div id="app" class="hidden">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-top">
<div class="brand">
<div class="brand-icon">⚕</div>
<div class="brand-name">Pharma<span>Comply</span></div>
<div class="brand-badge">AI</div>
</div>
<button class="new-btn" onclick="newThread()">+ &nbsp;New Conversation</button>
</div>
<div class="model-wrap">
<div class="section-label">Model</div>
<div class="model-pills">
<button class="model-pill active" data-model="llama-3.1-8b-instant" onclick="selectModel('llama-3.1-8b-instant',this)">⚡ 8B Fast</button>
<button class="model-pill" data-model="llama-3.3-70b-versatile" onclick="selectModel('llama-3.3-70b-versatile',this)">🧠 70B Deep</button>
</div>
</div>
<div class="threads-wrap">
<div class="section-label" style="padding:0 4px;margin-bottom:7px">Conversations</div>
<div id="thread-list"><div class="threads-empty">💬<br>No conversations yet.<br>Start one above.</div></div>
</div>
<div class="sidebar-footer">
<div class="user-card">
<div class="user-avatar" id="user-avatar">?</div>
<div class="user-info">
<div class="user-name" id="user-name">—</div>
<div class="user-role" id="user-role">—</div>
</div>
</div>
<div class="sidebar-actions">
<button class="sidebar-action-btn" onclick="toggleTheme()" id="theme-btn">🌙 Dark</button>
<button class="sidebar-action-btn logout" onclick="doLogout()">⎋ Sign out</button>
</div>
</div>
</aside>
<!-- MAIN CONTENT -->
<main class="main">
<header class="topbar">
<div class="topbar-left">
<div class="topbar-dot"></div>
<span class="topbar-title" id="topbar-title">Select or start a conversation</span>
</div>
<div class="topbar-right">
<div class="report-dropdown" id="report-dropdown">
<button class="top-btn primary" onclick="toggleReportMenu()">📋 Generate Report ▾</button>
<div class="report-menu" id="report-menu">
<div class="report-menu-item" onclick="generateReport('compliance_summary')">📊 Compliance Summary</div>
<div class="report-menu-item" onclick="generateReport('deviation_report')">⚠️ Deviation Report</div>
<div class="report-menu-item" onclick="generateReport('audit_findings')">🔍 Audit Findings</div>
<div class="report-menu-item" onclick="generateReport('pv_assessment')">💊 PV Safety Assessment</div>
<div class="report-menu-item" onclick="generateReport('regulatory_briefing')">📁 Regulatory Briefing</div>
</div>
</div>
<button class="top-btn" onclick="copyLastResponse()">⎘ Copy</button>
</div>
</header>
<div class="chat-window" id="chat-window">
<div class="chat-inner" id="chat-inner">
<div id="welcome-placeholder"></div>
</div>
</div>
<div class="report-panel" id="report-panel">
<div class="report-panel-inner">
<div class="report-header">
<div class="report-title-label">📋 <span id="report-panel-title">Generated Report</span></div>
<button class="report-close" onclick="closeReport()">✕</button>
</div>
<div class="report-content" id="report-content"></div>
<div class="report-actions">
<button class="report-action-btn primary" onclick="copyReport()">⎘ Copy</button>
<button class="report-action-btn" onclick="downloadReport()">⬇ Download .txt</button>
<button class="report-action-btn" onclick="closeReport()">Close</button>
</div>
</div>
</div>
<div class="input-area">
<div class="input-inner">
<div class="input-box">
<textarea id="user-input" placeholder="Ask about FDA regulations, GMP deviations, SAE reporting, CAPA, audit prep…" rows="1" onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="send-btn" id="send-btn" onclick="sendMessage()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<div class="input-footer">
<span>⚕ FDA · EMA · ICH · GCP · PV · GMP</span>
<span>For informational purposes only</span>
</div>
</div>
</div>
</main>
</div>
<template id="welcome-tpl">
<div class="welcome" id="welcome-screen">
<div class="welcome-icon">⚕</div>
<h1>Pharma<span>Comply</span> AI</h1>
<p>Your expert assistant for pharmaceutical regulatory compliance, pharmacovigilance, clinical trials, and quality systems.</p>
<div class="compliance-tags">
<span class="ctag fda">FDA 21 CFR</span><span class="ctag ema">EMA/GMP</span>
<span class="ctag ich">ICH Guidelines</span><span class="ctag gcp">GCP/GLP</span>
<span class="ctag pv">Pharmacovigilance</span><span class="ctag gmp">cGMP</span>
</div>
<div class="starter-grid">
<button class="starter-card" onclick="sendStarter(this)"><span class="sc-icon">⚠️</span><div class="sc-title">SAE Reporting</div><div class="sc-text">What are the 7-day expedited SAE reporting requirements under ICH E2A?</div></button>
<button class="starter-card" onclick="sendStarter(this)"><span class="sc-icon">🏭</span><div class="sc-title">GMP Deviation</div><div class="sc-text">How do I classify and handle a manufacturing deviation under 21 CFR 211?</div></button>
<button class="starter-card" onclick="sendStarter(this)"><span class="sc-icon">🔬</span><div class="sc-title">Audit Preparation</div><div class="sc-text">Prepare a checklist for an FDA pre-approval inspection of a sterile manufacturing site.</div></button>
<button class="starter-card" onclick="sendStarter(this)"><span class="sc-icon">📋</span><div class="sc-title">CAPA Plan</div><div class="sc-text">Draft a CAPA plan template for an OOS result in a finished product release test.</div></button>
<button class="starter-card" onclick="sendStarter(this)"><span class="sc-icon">📁</span><div class="sc-title">eCTD Submission</div><div class="sc-text">What modules are required for a 505(b)(1) NDA submission in eCTD format?</div></button>
<button class="starter-card" onclick="sendStarter(this)"><span class="sc-icon">🛡️</span><div class="sc-title">Data Integrity</div><div class="sc-text">Explain ALCOA+ principles and common data integrity failures in FDA warning letters.</div></button>
</div>
</div>
</template>
<script>
// ── Theme ────────────────────────────────────────────────────────────────────
let theme = localStorage.getItem('theme') || 'dark';
applyTheme(theme);
function applyTheme(t){
theme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
const icon = t === 'dark' ? '☀️ Light' : '🌙 Dark';
const licon = t === 'dark' ? '☀️' : '🌙';
const tb = document.getElementById('theme-btn');
const lb = document.getElementById('login-theme-btn');
if(tb) tb.textContent = icon;
if(lb) lb.textContent = licon;
}
function toggleTheme(){ applyTheme(theme === 'dark' ? 'light' : 'dark'); }
// ── Token helpers ─────────────────────────────────────────────────────────────
function getToken(){ return localStorage.getItem('auth_token'); }
function setToken(t){ localStorage.setItem('auth_token', t); }
function clearToken(){ localStorage.removeItem('auth_token'); }
function authHeaders(extra){
const token = getToken();
return Object.assign(
{ 'Authorization': token ? `Bearer ${token}` : '' },
extra || {}
);
}
// apiFetch — always attaches auth header, handles 401 globally
async function apiFetch(url, opts){
opts = opts || {};
opts.headers = Object.assign(authHeaders(opts.headers || {}));
const r = await fetch(url, opts);
if(r.status === 401){
// Only logout if we actually had a token (not on the login call itself)
if(getToken()){ doLogout(); }
throw new Error('401');
}
return r;
}
// ── Auth ──────────────────────────────────────────────────────────────────────
let currentUser = null;
function fillCred(u, p){
document.getElementById('login-user').value = u;
document.getElementById('login-pass').value = p;
}
function loginKey(e){ if(e.key === 'Enter') doLogin(); }
async function doLogin(){
const username = document.getElementById('login-user').value.trim();
const password = document.getElementById('login-pass').value;
const btn = document.getElementById('login-btn');
const errEl = document.getElementById('login-error');
errEl.classList.remove('show');
btn.textContent = 'Signing in…'; btn.disabled = true;
try{
const r = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if(!r.ok) throw new Error('bad');
const u = await r.json();
setToken(u.token);
currentUser = u;
showApp(u);
} catch {
errEl.classList.add('show');
}
btn.textContent = 'Sign In'; btn.disabled = false;
}
async function doLogout(){
try{
const token = getToken();
if(token){
await fetch('/api/logout', {
method: 'POST',
headers: authHeaders()
});
}
} catch{}
clearToken();
currentUser = null;
currentThreadId = null;
document.getElementById('app').classList.add('hidden');
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('login-user').value = '';
document.getElementById('login-pass').value = '';
document.getElementById('login-error').classList.remove('show');
}
function showApp(u){
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
document.getElementById('user-name').textContent = u.name;
document.getElementById('user-role').textContent = u.role;
document.getElementById('user-avatar').textContent = u.name.charAt(0).toUpperCase();
applyTheme(theme);
showWelcome();
loadThreads();
}
// ── State ─────────────────────────────────────────────────────────────────────
let currentThreadId = null;
let currentModel = 'llama-3.1-8b-instant';
let isStreaming = false;
// ── Utils ─────────────────────────────────────────────────────────────────────
function escHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function autoResize(el){ el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,160)+'px'; }
function handleKey(e){ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();} }
function scrollToBottom(){ const w=document.getElementById('chat-window'); w.scrollTop=w.scrollHeight; }
function selectModel(id,el){ currentModel=id; document.querySelectorAll('.model-pill').forEach(p=>p.classList.remove('active')); el.classList.add('active'); }
function fmtDate(iso){ if(!iso) return ''; const d=new Date(iso); return d.toLocaleDateString(undefined,{month:'short',day:'numeric'}); }
function renderMarkdown(text){
return text
.replace(/```(\w*)\n([\s\S]*?)```/g,(_,l,c)=>`<pre><code>${escHtml(c.trim())}</code></pre>`)
.replace(/`([^`\n]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{3} (.+)$/gm,'<h3>$1</h3>')
.replace(/^#{2} (.+)$/gm,'<h2>$1</h2>')
.replace(/^# (.+)$/gm,'<h1>$1</h1>')
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
.replace(/^\- (.+)$/gm,'<li>$1</li>')
.replace(/^(\d+)\. (.+)$/gm,'<li>$2</li>')
.replace(/(<li>[\s\S]*?<\/li>\n?)+/g,s=>`<ul>${s}</ul>`)
.replace(/\n\n+/g,'</p><p>')
.split('\n').filter(l=>l.trim()).join('\n');
}
// ── Threads ───────────────────────────────────────────────────────────────────
async function loadThreads(){
try{
const r = await apiFetch('/api/threads');
const threads = await r.json();
renderThreadList(threads);
} catch(e){ if(e.message!=='401') console.error(e); }
}
function renderThreadList(threads){
const el = document.getElementById('thread-list');
if(!threads.length){
el.innerHTML='<div class="threads-empty">💬<br>No conversations yet.<br>Start one above.</div>';
return;
}
el.innerHTML = threads.map(t=>`
<div class="thread-item ${t.id===currentThreadId?'active':''}" id="thread-${t.id}" onclick="selectThread('${t.id}')">
<div class="thread-item-title" id="thread-title-${t.id}">${escHtml(t.title)}</div>
<div class="thread-item-meta">${fmtDate(t.updated_at)} · ${t.message_count} msg${t.message_count!==1?'s':''}</div>
<div class="thread-actions">
<button class="thread-action-btn" title="Rename" onclick="startRename(event,'${t.id}','${escHtml(t.title).replace(/'/g,"\\'")}')">✏</button>
<button class="thread-action-btn del" title="Delete" onclick="deleteThread(event,'${t.id}')">🗑</button>
</div>
</div>`).join('');
}
async function newThread(){
try{
const r = await apiFetch('/api/threads', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({})
});
const t = await r.json();
currentThreadId = t.id;
await loadThreads();
showWelcome();
document.getElementById('topbar-title').textContent = t.title;
document.getElementById('user-input').focus();
} catch(e){ if(e.message!=='401') console.error(e); }
}
async function selectThread(id){
try{
currentThreadId = id;
const r = await apiFetch(`/api/threads/${id}`);
const t = await r.json();
await loadThreads();
document.getElementById('topbar-title').textContent = t.title;
const inner = document.getElementById('chat-inner');
if(!t.messages.length){ showWelcome(); return; }
inner.innerHTML='';
t.messages.forEach(m=>{
const bubble = addMessageBubble(m.role,'');
bubble.innerHTML = m.role==='assistant' ? renderMarkdown(m.content) : escHtml(m.content);
});
scrollToBottom();
} catch(e){ if(e.message!=='401') console.error(e); }
}
async function deleteThread(e,id){
e.stopPropagation();
if(!confirm('Delete this conversation?')) return;
try{
await apiFetch(`/api/threads/${id}`, {method:'DELETE'});
if(currentThreadId===id){
currentThreadId=null;
showWelcome();
document.getElementById('topbar-title').textContent='Select or start a conversation';
}
await loadThreads();
} catch(e){ if(e.message!=='401') console.error(e); }
}
function startRename(e,id,currentTitle){
e.stopPropagation();
const titleEl = document.getElementById(`thread-title-${id}`);
titleEl.innerHTML=`<input class="thread-rename-input" id="rename-input-${id}" value="${escHtml(currentTitle)}" onclick="event.stopPropagation()"/>`;
const input = document.getElementById(`rename-input-${id}`);
input.focus(); input.select();
let saved = false;
const finish = async ()=>{
if(saved) return; saved=true;
await doRename(id, input.value.trim()||currentTitle);
};
input.addEventListener('keydown', async ev=>{
if(ev.key==='Enter'){ev.preventDefault(); await finish();}
else if(ev.key==='Escape'){titleEl.textContent=currentTitle;}
});
input.addEventListener('blur', finish);
}
async function doRename(id,newTitle){
try{
await apiFetch(`/api/threads/${id}`, {
method:'PATCH',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({title:newTitle})
});
if(currentThreadId===id) document.getElementById('topbar-title').textContent=newTitle;
await loadThreads();
} catch(e){ if(e.message!=='401') console.error(e); }
}
// ── Chat ──────────────────────────────────────────────────────────────────────
function showWelcome(){
const inner = document.getElementById('chat-inner');
inner.innerHTML = document.getElementById('welcome-tpl').innerHTML;
}
function addMessageBubble(role, content){
const ws = document.getElementById('welcome-screen');
if(ws) ws.remove();
const inner = document.getElementById('chat-inner');
const div = document.createElement('div');
div.className=`message ${role}`;
div.innerHTML=`<div class="msg-header"><div class="msg-avatar">${role==='assistant'?'⚕':'U'}</div>${role==='assistant'?'PharmaComply AI':'You'}</div><div class="msg-bubble">${role==='assistant'?'':escHtml(content)}</div>`;
inner.appendChild(div);
scrollToBottom();
return div.querySelector('.msg-bubble');
}
function sendStarter(btn){ document.getElementById('user-input').value=btn.querySelector('.sc-text').textContent.trim(); sendMessage(); }
async function sendMessage(){
if(isStreaming) return;
const input = document.getElementById('user-input');
const text = input.value.trim();
if(!text) return;
// Auto-create thread if needed
if(!currentThreadId){
try{
const r = await apiFetch('/api/threads', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({})
});
const t = await r.json();
currentThreadId = t.id;
} catch(e){ return; }
}
input.value=''; input.style.height='auto';
document.getElementById('send-btn').disabled=true;
isStreaming=true;
addMessageBubble('user', text);
const bubble = addMessageBubble('assistant','');
const cursor = document.createElement('span');
cursor.className='cursor'; bubble.appendChild(cursor); scrollToBottom();
let fullText='';
try{
const res = await fetch('/api/chat', {
method:'POST',
headers: authHeaders({'Content-Type':'application/json'}),
body: JSON.stringify({thread_id:currentThreadId, message:text, model:currentModel})
});
if(res.status===401){ clearToken(); doLogout(); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder();
while(true){
const {done,value} = await reader.read();
if(done) break;
for(const line of decoder.decode(value).split('\n')){
if(line.startsWith('data: ')){
const d = line.slice(6);
if(d==='[DONE]') break;
try{
const j = JSON.parse(d);
if(j.content){ fullText+=j.content; bubble.innerHTML=renderMarkdown(fullText); bubble.appendChild(cursor); scrollToBottom(); }
} catch{}
}
}
}
} catch(e){
if(e.message!=='401') fullText='⚠️ Error — check your GROQ_API_KEY secret.';
}
cursor.remove();
bubble.innerHTML = renderMarkdown(fullText)||'<em style="color:var(--text-dim)">No response.</em>';
isStreaming=false;
document.getElementById('send-btn').disabled=false;
// Reload thread list without triggering logout on failure
try{ await loadThreads(); } catch{}
scrollToBottom();
}
// ── Reports ───────────────────────────────────────────────────────────────────
function toggleReportMenu(){ document.getElementById('report-menu').classList.toggle('open'); }
document.addEventListener('click',e=>{
const dd = document.getElementById('report-dropdown');
if(dd && !dd.contains(e.target)) document.getElementById('report-menu').classList.remove('open');
});
const REPORT_LABELS={
compliance_summary:'Compliance Summary',
deviation_report:'Deviation Report',
audit_findings:'Audit Findings',
pv_assessment:'PV Safety Assessment',
regulatory_briefing:'Regulatory Briefing'
};
async function generateReport(type){
document.getElementById('report-menu').classList.remove('open');
if(!currentThreadId){alert('Start a conversation first.');return;}
const panel = document.getElementById('report-panel');
const content = document.getElementById('report-content');
document.getElementById('report-panel-title').textContent = REPORT_LABELS[type]||'Report';
content.innerHTML='<em style="color:var(--text-dim)">Generating report…</em>';
panel.classList.add('visible');
let fullText='';
try{
const res = await fetch('/api/report',{
method:'POST',
headers: authHeaders({'Content-Type':'application/json'}),
body: JSON.stringify({thread_id:currentThreadId, report_type:type, model:'llama-3.3-70b-versatile'})
});
if(res.status===401){ doLogout(); return; }
const reader = res.body.getReader();
const decoder = new TextDecoder();
content.innerHTML='';
while(true){
const {done,value} = await reader.read(); if(done) break;
for(const line of decoder.decode(value).split('\n')){
if(line.startsWith('data: ')){
const d=line.slice(6); if(d==='[DONE]') break;
try{const j=JSON.parse(d);if(j.content){fullText+=j.content;content.innerHTML=renderMarkdown(fullText);}}catch{}
}
}
}
} catch { content.innerHTML='⚠️ Report generation failed.'; }
}
function closeReport(){ document.getElementById('report-panel').classList.remove('visible'); }
function copyReport(){ navigator.clipboard.writeText(document.getElementById('report-content').innerText).then(()=>alert('Copied!')); }
function downloadReport(){
const text = document.getElementById('report-content').innerText;
const title = document.getElementById('report-panel-title').textContent.replace(/\s+/g,'_');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text],{type:'text/plain'}));
a.download = `${title}_${new Date().toISOString().slice(0,10)}.txt`;
a.click();
}
function copyLastResponse(){
const bubbles = document.querySelectorAll('.message.assistant .msg-bubble');
if(!bubbles.length) return;
navigator.clipboard.writeText(bubbles[bubbles.length-1].innerText).then(()=>{
const btn=document.querySelector('.topbar-right .top-btn:last-child');
const old=btn.textContent; btn.textContent='✓ Copied'; setTimeout(()=>btn.textContent=old,1500);
});
}
// ── Init ──────────────────────────────────────────────────────────────────────
(async ()=>{
const token = getToken();
if(!token) return; // no token → stay on login screen
try{
const r = await fetch('/api/me', { headers: authHeaders() });
if(r.ok){
const u = await r.json();
currentUser = u;
showApp(u);
} else {
clearToken(); // stale token → clear and show login
}
} catch { clearToken(); }
})();
</script>
</body>
</html>"""