AuthorBot Cursor commited on
Commit
5ee07a1
Β·
1 Parent(s): 06c1465

feat: add home page, chat demo, and API docs for HF Spaces

Browse files
backend/app/api/v1/superadmin.py CHANGED
@@ -20,6 +20,22 @@ from app.repositories.audit_repo import AuditRepository
20
  router = APIRouter()
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  @router.get("/clients", response_model=ClientListResponse)
24
  async def list_clients(
25
  limit: int = Query(default=50, ge=1, le=100),
 
20
  router = APIRouter()
21
 
22
 
23
+ @router.get("/")
24
+ async def superadmin_index():
25
+ """Helpful index β€” lists available SuperAdmin endpoints."""
26
+ return {
27
+ "message": "SuperAdmin API. Authenticate with a SuperAdmin JWT (Bearer token).",
28
+ "endpoints": {
29
+ "list_clients": "GET /api/v1/superadmin/clients",
30
+ "client_detail": "GET /api/v1/superadmin/clients/{author_id}",
31
+ "grant_access": "POST /api/v1/superadmin/clients/{author_id}/grant",
32
+ "revoke_access": "POST /api/v1/superadmin/grants/{grant_id}/revoke",
33
+ "audit_log": "GET /api/v1/superadmin/audit",
34
+ },
35
+ "docs": "/docs",
36
+ }
37
+
38
+
39
  @router.get("/clients", response_model=ClientListResponse)
40
  async def list_clients(
41
  limit: int = Query(default=50, ge=1, le=100),
backend/app/main.py CHANGED
@@ -7,10 +7,13 @@ All middleware, exception handlers, and routers are registered here.
7
  from contextlib import asynccontextmanager
8
  from typing import AsyncGenerator
9
 
 
 
10
  import structlog
11
  from fastapi import FastAPI, Request
12
  from fastapi.middleware.cors import CORSMiddleware
13
- from fastapi.responses import JSONResponse
 
14
 
15
  from app.config import get_settings
16
  from app.api.v1 import auth, books, documents, chatbot, analytics, settings, links, superadmin
@@ -24,6 +27,7 @@ from app.middleware.logging_middleware import LoggingMiddleware
24
  from app.middleware.rate_limit_middleware import RateLimitMiddleware
25
 
26
  logger = structlog.get_logger(__name__)
 
27
 
28
 
29
  @asynccontextmanager
@@ -42,8 +46,8 @@ def create_app() -> FastAPI:
42
  title="AuthorBot API",
43
  description="RAG Chatbot SaaS for Author Websites",
44
  version="1.0.0",
45
- docs_url="/docs" if cfg.DEBUG else None,
46
- redoc_url="/redoc" if cfg.DEBUG else None,
47
  lifespan=lifespan,
48
  )
49
 
@@ -132,11 +136,24 @@ def _register_routers(app: FastAPI) -> None:
132
  app.include_router(links.router, prefix=f"{prefix}/links", tags=["Links"])
133
  app.include_router(superadmin.router, prefix=f"{prefix}/superadmin", tags=["Super Admin"])
134
 
 
 
 
 
 
 
 
 
 
 
135
  @app.get("/health", tags=["Health"])
136
  async def health_check() -> dict:
137
  """System health check β€” verifies DB, Redis, ChromaDB, and model status."""
138
  from app.dependencies import check_health
139
  return await check_health()
140
 
 
 
 
141
 
142
  app = create_app()
 
7
  from contextlib import asynccontextmanager
8
  from typing import AsyncGenerator
9
 
10
+ from pathlib import Path
11
+
12
  import structlog
13
  from fastapi import FastAPI, Request
14
  from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import HTMLResponse, JSONResponse
16
+ from fastapi.staticfiles import StaticFiles
17
 
18
  from app.config import get_settings
19
  from app.api.v1 import auth, books, documents, chatbot, analytics, settings, links, superadmin
 
27
  from app.middleware.rate_limit_middleware import RateLimitMiddleware
28
 
29
  logger = structlog.get_logger(__name__)
30
+ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
31
 
32
 
33
  @asynccontextmanager
 
46
  title="AuthorBot API",
47
  description="RAG Chatbot SaaS for Author Websites",
48
  version="1.0.0",
49
+ docs_url="/docs",
50
+ redoc_url="/redoc",
51
  lifespan=lifespan,
52
  )
53
 
 
136
  app.include_router(links.router, prefix=f"{prefix}/links", tags=["Links"])
137
  app.include_router(superadmin.router, prefix=f"{prefix}/superadmin", tags=["Super Admin"])
138
 
139
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
140
+ async def home() -> HTMLResponse:
141
+ """Landing page with links to the chat demo and API docs."""
142
+ return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
143
+
144
+ @app.get("/demo", response_class=HTMLResponse, include_in_schema=False)
145
+ async def chat_demo() -> HTMLResponse:
146
+ """Embedded chat widget demo page."""
147
+ return HTMLResponse((STATIC_DIR / "demo.html").read_text(encoding="utf-8"))
148
+
149
  @app.get("/health", tags=["Health"])
150
  async def health_check() -> dict:
151
  """System health check β€” verifies DB, Redis, ChromaDB, and model status."""
152
  from app.dependencies import check_health
153
  return await check_health()
154
 
155
+ if STATIC_DIR.is_dir():
156
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
157
+
158
 
159
  app = create_app()
backend/static/demo.html ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AuthorBot Chat Demo</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, 'Inter', sans-serif;
11
+ background: linear-gradient(135deg, #0d0f1a 0%, #13161f 100%);
12
+ min-height: 100vh; color: #e2e8f0;
13
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
14
+ padding: 24px;
15
+ }
16
+ .hero { text-align: center; max-width: 560px; }
17
+ h1 { font-size: 2.5rem; font-weight: 900; margin-bottom: 14px;
18
+ background: linear-gradient(135deg, #6366f1, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
19
+ p { color: #94a3b8; font-size: 1.125rem; line-height: 1.7; margin-bottom: 24px; }
20
+ .warn {
21
+ background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.35);
22
+ color: #fcd34d; padding: 12px 16px; border-radius: 12px; font-size: 14px; margin-bottom: 24px;
23
+ }
24
+ .controls { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 32px; }
25
+ .btn {
26
+ padding: 10px 22px; border-radius: 24px; border: 1px solid rgba(255,255,255,0.12);
27
+ background: rgba(255,255,255,0.06); color: #e2e8f0; cursor: pointer; font-size: 14px;
28
+ font-weight: 600; font-family: inherit; text-decoration: none;
29
+ }
30
+ .btn:hover { background: rgba(99,102,241,0.25); border-color: #6366f1; }
31
+ code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div class="hero">
36
+ <div style="font-size:3rem;margin-bottom:16px">✨</div>
37
+ <h1>AuthorBot Chat</h1>
38
+ <p id="intro">Click the chat bubble in the bottom-right corner to talk to the bot.</p>
39
+ <div id="token-warn" class="warn" style="display:none">
40
+ Add your subscription token to the URL:<br>
41
+ <code>/demo?token=YOUR_SUBSCRIPTION_TOKEN</code>
42
+ </div>
43
+ <div class="controls">
44
+ <button class="btn" onclick="AuthorBot.open()">Open Chat</button>
45
+ <button class="btn" onclick="AuthorBot.close()">Close Chat</button>
46
+ <a class="btn" href="/">← Back home</a>
47
+ </div>
48
+ </div>
49
+
50
+ <script>
51
+ const params = new URLSearchParams(window.location.search);
52
+ const token = params.get('token') || '';
53
+ if (!token) {
54
+ document.getElementById('token-warn').style.display = 'block';
55
+ }
56
+ window.AuthorBotConfig = {
57
+ token: token,
58
+ theme: params.get('theme') || 'midnight',
59
+ position: params.get('position') || 'bottom-right',
60
+ autoOpenDelay: Number(params.get('autoOpen') || 2),
61
+ apiBase: window.location.origin + '/api/v1',
62
+ };
63
+ </script>
64
+ <script src="/static/widget.js"></script>
65
+ </body>
66
+ </html>
backend/static/index.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AuthorBot RAG</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
11
+ background: #0d0f1a; color: #e2e8f0; min-height: 100vh;
12
+ display: flex; align-items: center; justify-content: center; padding: 24px;
13
+ }
14
+ .card {
15
+ max-width: 640px; width: 100%;
16
+ background: #13161f; border: 1px solid rgba(255,255,255,0.08);
17
+ border-radius: 20px; padding: 40px;
18
+ box-shadow: 0 24px 80px rgba(0,0,0,0.45);
19
+ }
20
+ h1 {
21
+ font-size: 2rem; margin-bottom: 8px;
22
+ background: linear-gradient(135deg, #6366f1, #a78bfa);
23
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
24
+ }
25
+ .sub { color: #94a3b8; margin-bottom: 28px; line-height: 1.6; }
26
+ .links { display: flex; flex-direction: column; gap: 12px; }
27
+ a.link {
28
+ display: flex; align-items: center; justify-content: space-between;
29
+ padding: 14px 18px; border-radius: 12px;
30
+ background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
31
+ color: #e2e8f0; text-decoration: none; transition: border-color 0.2s, background 0.2s;
32
+ }
33
+ a.link:hover { border-color: #6366f1; background: rgba(99,102,241,0.12); }
34
+ a.link strong { font-size: 15px; }
35
+ a.link span { color: #64748b; font-size: 13px; }
36
+ .badge {
37
+ display: inline-block; margin-top: 24px; padding: 6px 12px;
38
+ border-radius: 999px; font-size: 12px; font-weight: 600;
39
+ background: rgba(34,197,94,0.15); color: #4ade80; border: 1px solid rgba(34,197,94,0.3);
40
+ }
41
+ .note { margin-top: 20px; font-size: 13px; color: #64748b; line-height: 1.5; }
42
+ code { background: rgba(255,255,255,0.06); padding: 2px 6px; border-radius: 4px; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div class="card">
47
+ <h1>AuthorBot RAG</h1>
48
+ <p class="sub">Your AI book advisor API is running on Hugging Face Spaces.</p>
49
+ <div class="links">
50
+ <a class="link" href="/demo">
51
+ <strong>πŸ’¬ Try the chatbot</strong>
52
+ <span>Open demo β†’</span>
53
+ </a>
54
+ <a class="link" href="/health">
55
+ <strong>❀️ Health check</strong>
56
+ <span>API status β†’</span>
57
+ </a>
58
+ <a class="link" href="/docs">
59
+ <strong>πŸ“– API docs</strong>
60
+ <span>Swagger UI β†’</span>
61
+ </a>
62
+ </div>
63
+ <div class="badge">● Online</div>
64
+ <p class="note">
65
+ To use the chatbot, open <code>/demo?token=YOUR_SUBSCRIPTION_TOKEN</code>.<br>
66
+ Register via <code>POST /api/v1/auth/register</code> in the API docs, then grant a subscription as SuperAdmin.
67
+ </p>
68
+ </div>
69
+ </body>
70
+ </html>
backend/static/widget.js ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AuthorBot Chat Widget β€” widget.js
3
+ * Vanilla JS, zero dependencies, ~8KB minified.
4
+ * Embeds as a floating chat bubble on any author website.
5
+ *
6
+ * Usage:
7
+ * window.AuthorBotConfig = { token: "YOUR_TOKEN", theme: "midnight", position: "bottom-right" };
8
+ * <script src="https://cdn.authorbot.io/widget.min.js" async></script>
9
+ */
10
+
11
+ (function() {
12
+ 'use strict';
13
+
14
+ // ── Configuration ──────────────────────────────────────────
15
+ const CONFIG = Object.assign({
16
+ theme: 'midnight',
17
+ position: 'bottom-right',
18
+ autoOpenDelay: 0,
19
+ apiBase: 'https://api.authorbot.io/api/v1',
20
+ }, window.AuthorBotConfig || {});
21
+
22
+ if (!CONFIG.token) {
23
+ console.warn('[AuthorBot] No token provided. Widget disabled.');
24
+ return;
25
+ }
26
+
27
+ // ── Theme Definitions ──────────────────────────────────────
28
+ const THEMES = {
29
+ midnight: { bg: '#0d0f1a', surface: '#13161f', border: '#ffffff0f', brand: '#6366f1', text: '#e2e8f0', sub: '#94a3b8' },
30
+ ocean: { bg: '#0a1628', surface: '#0f1e38', border: '#ffffff0f', brand: '#0ea5e9', text: '#e0f2fe', sub: '#7dd3fc' },
31
+ forest: { bg: '#0a1a0f', surface: '#0f2416', border: '#ffffff0f', brand: '#22c55e', text: '#dcfce7', sub: '#86efac' },
32
+ sunset: { bg: '#1a0d0a', surface: '#261510', border: '#ffffff0f', brand: '#f97316', text: '#fff7ed', sub: '#fdba74' },
33
+ minimal: { bg: '#ffffff', surface: '#f8fafc', border: '#e2e8f0', brand: '#4f46e5', text: '#1e293b', sub: '#64748b' },
34
+ };
35
+
36
+ const T = THEMES[CONFIG.theme] || THEMES.midnight;
37
+ const isDark = CONFIG.theme !== 'minimal';
38
+
39
+ // ── State ───────────────────────────────────────────────────
40
+ let sessionId = null;
41
+ let isOpen = false;
42
+ let isLoading = false;
43
+ let selectedBookId = null;
44
+ let books = [];
45
+ let turnCount = 0;
46
+
47
+ // ── Position helpers ────────────────────────────────────────
48
+ const POS = CONFIG.position.split('-');
49
+ const vPos = POS[0]; // top / bottom
50
+ const hPos = POS[1]; // left / right
51
+
52
+ // ── CSS injection ────────────────────────────────────────────
53
+ const style = document.createElement('style');
54
+ style.textContent = `
55
+ #ab-root * { box-sizing: border-box; font-family: -apple-system, 'Inter', BlinkMacSystemFont, sans-serif; }
56
+ #ab-root { position: fixed; ${vPos}: 24px; ${hPos}: 24px; z-index: 2147483647; display: flex; flex-direction: column; align-items: ${hPos === 'right' ? 'flex-end' : 'flex-start'}; gap: 12px; }
57
+
58
+ #ab-bubble {
59
+ width: 56px; height: 56px; border-radius: 50%;
60
+ background: linear-gradient(135deg, ${T.brand}, ${T.brand}cc);
61
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
62
+ font-size: 24px; border: none; outline: none;
63
+ box-shadow: 0 4px 20px ${T.brand}55;
64
+ transition: transform 0.25s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
65
+ animation: ab-float 4s ease-in-out infinite;
66
+ }
67
+ #ab-bubble:hover { transform: scale(1.1); box-shadow: 0 6px 28px ${T.brand}80; }
68
+
69
+ #ab-window {
70
+ width: 370px; max-height: 560px;
71
+ background: ${T.bg}; border: 1px solid ${T.border};
72
+ border-radius: 20px; display: flex; flex-direction: column;
73
+ box-shadow: 0 24px 80px rgba(0,0,0,0.6);
74
+ overflow: hidden;
75
+ transform-origin: ${hPos === 'right' ? 'bottom right' : 'bottom left'};
76
+ transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.2s;
77
+ }
78
+ #ab-window.ab-hidden { transform: scale(0.85); opacity: 0; pointer-events: none; }
79
+
80
+ #ab-header {
81
+ background: ${T.surface}; padding: 14px 16px;
82
+ border-bottom: 1px solid ${T.border};
83
+ display: flex; align-items: center; gap: 10px; flex-shrink: 0;
84
+ }
85
+ #ab-avatar {
86
+ width: 36px; height: 36px; border-radius: 50%;
87
+ background: linear-gradient(135deg, ${T.brand}, ${T.brand}99);
88
+ display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0;
89
+ }
90
+ #ab-bot-name { font-weight: 700; font-size: 14px; color: ${T.text}; }
91
+ #ab-status { font-size: 11px; color: #22c55e; display: flex; align-items: center; gap: 4px; }
92
+ #ab-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 6px #22c55e; }
93
+ #ab-close-btn { margin-left: auto; background: none; border: none; cursor: pointer; color: ${T.sub}; font-size: 18px; line-height: 1; padding: 2px; }
94
+
95
+ #ab-messages {
96
+ flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px;
97
+ scrollbar-width: thin; scrollbar-color: ${T.border} transparent;
98
+ }
99
+
100
+ .ab-msg { display: flex; flex-direction: column; max-width: 85%; animation: ab-slide-up 0.2s ease; }
101
+ .ab-msg.ab-bot { align-self: flex-start; }
102
+ .ab-msg.ab-user { align-self: flex-end; }
103
+
104
+ .ab-bubble-text {
105
+ padding: 10px 14px; border-radius: 16px; font-size: 13.5px; line-height: 1.55;
106
+ }
107
+ .ab-bot .ab-bubble-text {
108
+ background: ${T.surface}; border: 1px solid ${T.border}; color: ${T.text};
109
+ border-bottom-left-radius: 4px;
110
+ }
111
+ .ab-user .ab-bubble-text {
112
+ background: ${T.brand}; color: #fff; border-bottom-right-radius: 4px;
113
+ }
114
+
115
+ .ab-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
116
+ .ab-link-btn {
117
+ display: inline-flex; align-items: center; gap: 5px;
118
+ padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600;
119
+ background: ${T.brand}22; color: ${T.brand}; border: 1px solid ${T.brand}44;
120
+ cursor: pointer; text-decoration: none; transition: all 0.15s;
121
+ }
122
+ .ab-link-btn:hover { background: ${T.brand}; color: #fff; }
123
+
124
+ .ab-book-selector { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
125
+ .ab-book-btn {
126
+ display: flex; align-items: center; gap: 10px;
127
+ padding: 8px 12px; border-radius: 12px; cursor: pointer;
128
+ background: ${T.surface}; border: 1px solid ${T.border};
129
+ transition: all 0.15s; font-size: 13px; color: ${T.text};
130
+ }
131
+ .ab-book-btn:hover { border-color: ${T.brand}; background: ${T.brand}15; }
132
+ .ab-book-thumb { font-size: 20px; flex-shrink: 0; }
133
+
134
+ .ab-typing { display: flex; gap: 4px; padding: 12px 14px; }
135
+ .ab-dot {
136
+ width: 6px; height: 6px; border-radius: 50%;
137
+ background: ${T.sub}; animation: ab-bounce 1.2s ease infinite;
138
+ }
139
+ .ab-dot:nth-child(2) { animation-delay: 0.2s; }
140
+ .ab-dot:nth-child(3) { animation-delay: 0.4s; }
141
+
142
+ #ab-input-bar {
143
+ padding: 12px 14px; border-top: 1px solid ${T.border};
144
+ display: flex; gap: 8px; flex-shrink: 0; background: ${T.surface};
145
+ }
146
+ #ab-input {
147
+ flex: 1; background: ${T.bg}; border: 1px solid ${T.border};
148
+ border-radius: 20px; padding: 8px 14px; font-size: 13px; color: ${T.text};
149
+ outline: none; resize: none; font-family: inherit;
150
+ transition: border-color 0.15s;
151
+ }
152
+ #ab-input::placeholder { color: ${T.sub}; }
153
+ #ab-input:focus { border-color: ${T.brand}; }
154
+ #ab-send {
155
+ width: 36px; height: 36px; border-radius: 50%; border: none; cursor: pointer;
156
+ background: linear-gradient(135deg, ${T.brand}, ${T.brand}cc);
157
+ color: #fff; font-size: 16px; display: flex; align-items: center; justify-content: center;
158
+ transition: all 0.2s; flex-shrink: 0; align-self: flex-end;
159
+ }
160
+ #ab-send:hover { transform: scale(1.1); }
161
+ #ab-send:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
162
+
163
+ #ab-branding {
164
+ text-align: center; padding: 6px; font-size: 10px; color: ${T.sub}; opacity: 0.6;
165
+ background: ${T.surface}; border-top: 1px solid ${T.border};
166
+ }
167
+
168
+ @keyframes ab-float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-5px)} }
169
+ @keyframes ab-slide-up { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
170
+ @keyframes ab-bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
171
+ `;
172
+ document.head.appendChild(style);
173
+
174
+ // ── DOM construction ─────────────────────────────────────────
175
+ const root = document.createElement('div');
176
+ root.id = 'ab-root';
177
+ root.innerHTML = `
178
+ <div id="ab-window" class="ab-hidden">
179
+ <div id="ab-header">
180
+ <div id="ab-avatar">πŸ€–</div>
181
+ <div>
182
+ <div id="ab-bot-name">Book Advisor</div>
183
+ <div id="ab-status">Online</div>
184
+ </div>
185
+ <button id="ab-close-btn" aria-label="Close chat">Γ—</button>
186
+ </div>
187
+ <div id="ab-messages" role="log" aria-live="polite"></div>
188
+ <div id="ab-input-bar">
189
+ <textarea id="ab-input" rows="1" placeholder="Ask me anything about the books..." aria-label="Chat input"></textarea>
190
+ <button id="ab-send" aria-label="Send message">↑</button>
191
+ </div>
192
+ <div id="ab-branding">Powered by AuthorBot</div>
193
+ </div>
194
+ <button id="ab-bubble" aria-label="Open book advisor chat">πŸ’¬</button>
195
+ `;
196
+
197
+ // Bubble is above window β€” reverse for bottom positioning
198
+ if (vPos === 'bottom') {
199
+ root.style.flexDirection = 'column-reverse';
200
+ }
201
+
202
+ document.body.appendChild(root);
203
+
204
+ // ── DOM refs ─────────────────────────────────────────────────
205
+ const $window = root.querySelector('#ab-window');
206
+ const $messages = root.querySelector('#ab-messages');
207
+ const $input = root.querySelector('#ab-input');
208
+ const $send = root.querySelector('#ab-send');
209
+ const $bubble = root.querySelector('#ab-bubble');
210
+ const $close = root.querySelector('#ab-close-btn');
211
+ const $botName = root.querySelector('#ab-bot-name');
212
+
213
+ // ── Toggle open/close ─────────────────────────────────────────
214
+ function openChat() {
215
+ isOpen = true;
216
+ $window.classList.remove('ab-hidden');
217
+ $bubble.style.display = 'none';
218
+ if (!sessionId) initSession();
219
+ else $input.focus();
220
+ }
221
+
222
+ function closeChat() {
223
+ isOpen = false;
224
+ $window.classList.add('ab-hidden');
225
+ $bubble.style.display = '';
226
+ }
227
+
228
+ $bubble.addEventListener('click', openChat);
229
+ $close.addEventListener('click', closeChat);
230
+
231
+ // ── Session init ──────────────────────────────────────────────
232
+ async function initSession() {
233
+ try {
234
+ const res = await apiPost('/chat/session', {}, { 'X-Subscription-Token': CONFIG.token });
235
+ sessionId = res.session_id;
236
+ books = res.books || [];
237
+ $botName.textContent = res.bot_name || 'Book Advisor';
238
+ addBotMessage(res.welcome_message || 'Hi! How can I help you today?', [], null);
239
+ if (res.show_book_selector && books.length > 1) {
240
+ addBotMessage("Which book are you curious about?", [], books);
241
+ }
242
+ $input.focus();
243
+ } catch (e) {
244
+ addBotMessage("Sorry, I'm having trouble starting. Please refresh and try again.", [], null);
245
+ }
246
+ }
247
+
248
+ // ── Send message ──────────────────────────────────────────────
249
+ async function sendMessage() {
250
+ const text = $input.value.trim();
251
+ if (!text || isLoading || !sessionId) return;
252
+
253
+ addUserMessage(text);
254
+ $input.value = '';
255
+ resizeInput();
256
+
257
+ const typingEl = addTypingIndicator();
258
+ isLoading = true;
259
+ $send.disabled = true;
260
+ turnCount++;
261
+
262
+ try {
263
+ const res = await apiPost('/chat/chat', {
264
+ session_id: sessionId,
265
+ message: text,
266
+ selected_book_id: selectedBookId,
267
+ }, { 'X-Subscription-Token': CONFIG.token });
268
+
269
+ typingEl.remove();
270
+ addBotMessage(res.text, res.links || [], res.book_selector || null);
271
+ } catch (e) {
272
+ typingEl.remove();
273
+ addBotMessage("Sorry, something went wrong. Please try again.", [], null);
274
+ } finally {
275
+ isLoading = false;
276
+ $send.disabled = false;
277
+ $input.focus();
278
+ }
279
+ }
280
+
281
+ // ── Message rendering ─────────────────────────────────────────
282
+ function addUserMessage(text) {
283
+ const el = document.createElement('div');
284
+ el.className = 'ab-msg ab-user';
285
+ el.innerHTML = `<div class="ab-bubble-text">${escHtml(text)}</div>`;
286
+ $messages.appendChild(el);
287
+ scrollToBottom();
288
+ }
289
+
290
+ function addBotMessage(text, links, bookSelector) {
291
+ const el = document.createElement('div');
292
+ el.className = 'ab-msg ab-bot';
293
+
294
+ let html = `<div class="ab-bubble-text">${escHtml(text)}</div>`;
295
+
296
+ if (links && links.length) {
297
+ html += `<div class="ab-links">`;
298
+ links.forEach(l => {
299
+ html += `<a class="ab-link-btn" href="${escAttr(l.url)}" target="_blank" rel="noopener" data-type="${escAttr(l.type)}">${escHtml(l.icon)} ${escHtml(l.label)}</a>`;
300
+ });
301
+ html += `</div>`;
302
+ }
303
+
304
+ if (bookSelector && bookSelector.length) {
305
+ html += `<div class="ab-book-selector">`;
306
+ bookSelector.forEach(b => {
307
+ html += `<button class="ab-book-btn" data-book-id="${escAttr(b.id)}">
308
+ <span class="ab-book-thumb">πŸ“–</span>
309
+ <div style="text-align:left">
310
+ <div style="font-weight:600;font-size:13px">${escHtml(b.title)}</div>
311
+ ${b.tagline ? `<div style="font-size:11px;opacity:0.7;margin-top:1px">${escHtml(b.tagline)}</div>` : ''}
312
+ </div>
313
+ </button>`;
314
+ });
315
+ html += `</div>`;
316
+ }
317
+
318
+ el.innerHTML = html;
319
+
320
+ // Book selection handler
321
+ el.querySelectorAll('.ab-book-btn').forEach(btn => {
322
+ btn.addEventListener('click', () => {
323
+ const bookId = btn.getAttribute('data-book-id');
324
+ selectedBookId = bookId;
325
+ const book = bookSelector.find(b => b.id === bookId);
326
+ addUserMessage(`Tell me about "${book?.title}"`);
327
+ apiPost('/chat/chat', {
328
+ session_id: sessionId,
329
+ message: `Tell me about "${book?.title}"`,
330
+ selected_book_id: bookId,
331
+ }, { 'X-Subscription-Token': CONFIG.token }).then(res => {
332
+ addBotMessage(res.text, res.links || [], null);
333
+ });
334
+ // Disable all book buttons after selection
335
+ el.querySelectorAll('.ab-book-btn').forEach(b => b.style.opacity = '0.4');
336
+ btn.style.opacity = '1'; btn.style.borderColor = T.brand;
337
+ });
338
+ });
339
+
340
+ // Link click tracking
341
+ el.querySelectorAll('.ab-link-btn').forEach(a => {
342
+ a.addEventListener('click', () => {
343
+ try {
344
+ apiPost('/chat/track-click', {
345
+ session_id: sessionId,
346
+ link_type: a.getAttribute('data-type'),
347
+ }, { 'X-Subscription-Token': CONFIG.token }).catch(() => {});
348
+ } catch {}
349
+ });
350
+ });
351
+
352
+ $messages.appendChild(el);
353
+ scrollToBottom();
354
+ }
355
+
356
+ function addTypingIndicator() {
357
+ const el = document.createElement('div');
358
+ el.className = 'ab-msg ab-bot';
359
+ el.innerHTML = `<div class="ab-bubble-text ab-typing"><div class="ab-dot"></div><div class="ab-dot"></div><div class="ab-dot"></div></div>`;
360
+ $messages.appendChild(el);
361
+ scrollToBottom();
362
+ return el;
363
+ }
364
+
365
+ // ── Input handling ─────────────────────────────────────────────
366
+ function resizeInput() {
367
+ $input.style.height = 'auto';
368
+ $input.style.height = Math.min($input.scrollHeight, 100) + 'px';
369
+ }
370
+
371
+ $input.addEventListener('input', resizeInput);
372
+ $input.addEventListener('keydown', e => {
373
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
374
+ });
375
+ $send.addEventListener('click', sendMessage);
376
+
377
+ // ── Utilities ──────────────────────────────────────────────────
378
+ function scrollToBottom() {
379
+ requestAnimationFrame(() => {
380
+ $messages.scrollTop = $messages.scrollHeight;
381
+ });
382
+ }
383
+
384
+ function escHtml(str) {
385
+ return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
386
+ }
387
+
388
+ function escAttr(str) {
389
+ return String(str || '').replace(/"/g, '&quot;');
390
+ }
391
+
392
+ async function apiPost(path, body, headers) {
393
+ const base = CONFIG.apiBase || 'https://api.authorbot.io/api/v1';
394
+ const res = await fetch(`${base}${path}`, {
395
+ method: 'POST',
396
+ headers: { 'Content-Type': 'application/json', ...headers },
397
+ body: JSON.stringify(body),
398
+ });
399
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
400
+ return res.json();
401
+ }
402
+
403
+ // ── Auto-open ──────────────────────────────────────────────────
404
+ if (CONFIG.autoOpenDelay > 0) {
405
+ setTimeout(openChat, CONFIG.autoOpenDelay * 1000);
406
+ }
407
+
408
+ // ── Expose to window for programmatic control ──────────────────
409
+ window.AuthorBot = {
410
+ open: openChat,
411
+ close: closeChat,
412
+ toggle: () => isOpen ? closeChat() : openChat(),
413
+ };
414
+
415
+ })();