diff --git a/public/index.html b/public/index.html index a060227..51832b9 100644 --- a/public/index.html +++ b/public/index.html @@ -24,6 +24,29 @@ .badge { display: inline-block; padding: 2px 10px; border-radius: 9999px; font-size: .75rem; font-weight: 600; } .tag-pill { cursor: pointer; transition: background .15s; } .tag-pill.active { background: #4F46E5; color: #fff; } + + /* ── Chat widget ────────────────────────────────────────────────────── */ + #chat-widget { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 1000; display: flex; flex-direction: column; align-items: flex-end; gap: .75rem; } + #chat-toggle { width: 3.5rem; height: 3.5rem; border-radius: 9999px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; box-shadow: 0 4px 16px rgba(79,70,229,.4); background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; transition: transform .2s, box-shadow .2s; } + #chat-toggle:hover { transform: scale(1.08); box-shadow: 0 6px 22px rgba(79,70,229,.5); } + #chat-window { width: 22rem; max-width: calc(100vw - 3rem); background: #fff; border-radius: 1.25rem; box-shadow: 0 8px 40px rgba(0,0,0,.14); display: flex; flex-direction: column; overflow: hidden; } + #chat-window.hidden { display: none !important; } + #chat-header { background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; padding: .875rem 1.25rem; display: flex; align-items: center; justify-content: space-between; } + #chat-header h4 { margin: 0; font-size: .9375rem; font-weight: 700; } + #chat-header p { margin: 0; font-size: .75rem; opacity: .8; } + #chat-close { background: none; border: none; color: #fff; cursor: pointer; font-size: 1.1rem; opacity: .8; line-height: 1; padding: 0; } + #chat-close:hover { opacity: 1; } + #chat-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: .625rem; min-height: 14rem; max-height: 22rem; } + .chat-msg { max-width: 85%; padding: .5rem .875rem; border-radius: 1rem; font-size: .875rem; line-height: 1.5; word-break: break-word; } + .chat-msg.bot { background: #f1f5f9; color: #1e293b; align-self: flex-start; border-bottom-left-radius: .25rem; } + .chat-msg.user { background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; align-self: flex-end; border-bottom-right-radius: .25rem; } + .chat-msg.typing { opacity: .6; font-style: italic; } + #chat-form { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #f1f5f9; } + #chat-input { flex: 1; border: 1px solid #e2e8f0; border-radius: .75rem; padding: .5rem .875rem; font-size: .875rem; outline: none; transition: border-color .15s; } + #chat-input:focus { border-color: #4F46E5; } + #chat-send { background: linear-gradient(135deg,#4F46E5,#7C3AED); color: #fff; border: none; border-radius: .75rem; padding: .5rem .875rem; cursor: pointer; font-size: .875rem; font-weight: 600; transition: opacity .15s; } + #chat-send:hover { opacity: .9; } + #chat-send:disabled { opacity: .5; cursor: not-allowed; } @@ -146,6 +169,27 @@

Flexible Activities

© 2024–2026 EduPlatform · Cloudflare Python Workers + D1 · All user data encrypted at rest

+ +
+ + +
+ diff --git a/src/worker.py b/src/worker.py index 5442106..672cfbf 100644 --- a/src/worker.py +++ b/src/worker.py @@ -14,6 +14,7 @@ POST /api/sessions – add a session to activity [host] GET /api/tags – list all tags POST /api/activity-tags – add tags to an activity [host] + POST /api/chat – chat with the AI assistant (Cloudflare AI) Security model * ALL user PII (username, email, display name, role) is encrypted with a @@ -1112,6 +1113,69 @@ async def serve_static(path: str, env): return Response(content, headers={"Content-Type": mime, **_CORS}) +# --------------------------------------------------------------------------- +# AI chat endpoint +# --------------------------------------------------------------------------- + +_CHAT_SYSTEM_PROMPT = ( + "You are a helpful learning assistant for EduPlatform, an online education " + "platform where learners discover and join courses, workshops, meetups, and " + "study groups. Help users find activities, understand how the platform works, " + "and answer general learning questions. Keep answers concise and friendly." +) + +_MAX_HISTORY = 10 # maximum number of user+assistant turns kept per request + + +async def api_chat(request, env): + """POST /api/chat – send a message and receive an AI reply.""" + data, error = await parse_json_object(request) + if error: + return error + + message = (data.get("message") or "").strip() + if not message: + return err("message is required", 400) + if len(message) > 2000: + return err("message too long (max 2000 characters)", 400) + + # Optional conversation history – list of {role, content} dicts + history = data.get("history") or [] + if not isinstance(history, list): + history = [] + + # Sanitise history: keep only valid role/content pairs, trim to last N turns + clean_history = [] + for entry in history: + if ( + isinstance(entry, dict) + and entry.get("role") in ("user", "assistant") + and isinstance(entry.get("content"), str) + and entry["content"].strip() + ): + clean_history.append({ + "role": entry["role"], + "content": entry["content"][:2000], + }) + clean_history = clean_history[-(_MAX_HISTORY * 2):] + + messages = [{"role": "system", "content": _CHAT_SYSTEM_PROMPT}] + messages.extend(clean_history) + messages.append({"role": "user", "content": message}) + + try: + result = await env.AI.run( + "@cf/meta/llama-3.1-8b-instruct", + {"messages": messages}, + ) + reply = result.get("response") or "" + except Exception as exc: + capture_exception(exc, request, env, "api_chat") + return err("AI service unavailable", 503) + + return ok({"reply": reply}) + + # --------------------------------------------------------------------------- # Main dispatcher # --------------------------------------------------------------------------- @@ -1181,6 +1245,9 @@ async def _dispatch(request, env): if path == "/api/admin/table-counts" and method == "GET": return await api_admin_table_counts(request, env) + if path == "/api/chat" and method == "POST": + return await api_chat(request, env) + return err("API endpoint not found", 404) return await serve_static(path, env) diff --git a/wrangler.toml b/wrangler.toml index c281b20..76ab589 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,6 +17,9 @@ binding = "DB" database_name = "education_db" database_id = "a0021f2e-a8cc-4e20-8910-3c7290ba47a6" +[ai] +binding = "AI" + [observability] enabled = false