| 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")) |
|
|
| |
| _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)) |
|
|
| |
| |
| 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 = { |
| "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.""" |
|
|
| |
| 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" |
|
|
| |
| @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"]} |
|
|
| |
| @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} |
|
|
| |
| @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", |
| } |
| ) |
|
|
| |
| @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", |
| } |
| ) |
|
|
| |
| @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()">+ 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,'&').replace(/</g,'<').replace(/>/g,'>'); } |
| 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>""" |
|
|