From a5ab9a86a3afc9409cdce9304e7482e14e1fc49e Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 13:13:46 -0700 Subject: [PATCH 01/11] Add OCS Assistant: site-wide AI chatbot with navigation and saved chats - Floating chatbot widget injected on every page via the shared minima base layout, with a page.disable_ocs_bot opt-out for pages that shouldn't show it. - Answers questions about courses, lessons, and projects, and navigates users to the right page via validated [[GO:/path]] action chips backed by a curated index of real, verified site permalinks (so it never produces a broken link). - Powered by the existing Flask Groq proxy (/api/groq/chat); the API key stays server-side and is never exposed to the browser. - Per-user conversation history in localStorage, namespaced by the signed-in OCS user (via /api/id), with graceful optional server-side sync. - Multi-conversation rail (new/switch/search/delete), markdown + code rendering with copy buttons, typewriter streaming, starter suggestions, follow-up chips, and settings (answer style, model). Dark, responsive, accessible design. --- _includes/chatbot/ocs-bot.html | 126 +++++++ _includes/custom-head.html | 5 + _includes/themes/minima/base.html | 7 + assets/css/ocs-bot.css | 412 +++++++++++++++++++++++ assets/js/ocs-bot/api.js | 150 +++++++++ assets/js/ocs-bot/config.js | 42 +++ assets/js/ocs-bot/index.js | 541 ++++++++++++++++++++++++++++++ assets/js/ocs-bot/knowledge.js | 98 ++++++ assets/js/ocs-bot/prompt.js | 64 ++++ assets/js/ocs-bot/render.js | Bin 0 -> 3930 bytes assets/js/ocs-bot/store.js | 178 ++++++++++ assets/js/ocs-bot/suggestions.js | 31 ++ assets/js/ocs-bot/tools.js | 56 ++++ 13 files changed, 1710 insertions(+) create mode 100644 _includes/chatbot/ocs-bot.html create mode 100644 assets/css/ocs-bot.css create mode 100644 assets/js/ocs-bot/api.js create mode 100644 assets/js/ocs-bot/config.js create mode 100644 assets/js/ocs-bot/index.js create mode 100644 assets/js/ocs-bot/knowledge.js create mode 100644 assets/js/ocs-bot/prompt.js create mode 100644 assets/js/ocs-bot/render.js create mode 100644 assets/js/ocs-bot/store.js create mode 100644 assets/js/ocs-bot/suggestions.js create mode 100644 assets/js/ocs-bot/tools.js diff --git a/_includes/chatbot/ocs-bot.html b/_includes/chatbot/ocs-bot.html new file mode 100644 index 0000000000..b3e8270b85 --- /dev/null +++ b/_includes/chatbot/ocs-bot.html @@ -0,0 +1,126 @@ + + + + + + + + + + diff --git a/_includes/custom-head.html b/_includes/custom-head.html index 84f72b1c41..560fed53e6 100644 --- a/_includes/custom-head.html +++ b/_includes/custom-head.html @@ -2,4 +2,9 @@ {% include head-custom.html %} + +{%- unless page.disable_ocs_bot -%} + + +{%- endunless -%} \ No newline at end of file diff --git a/_includes/themes/minima/base.html b/_includes/themes/minima/base.html index 34cd9a77b9..f936c183f6 100644 --- a/_includes/themes/minima/base.html +++ b/_includes/themes/minima/base.html @@ -40,5 +40,12 @@ {%- include themes/minima/footer.html -%} {%- include mermaid.html -%} + + {%- unless page.disable_ocs_bot -%} + + {%- include chatbot/ocs-bot.html -%} + + + {%- endunless -%} \ No newline at end of file diff --git a/assets/css/ocs-bot.css b/assets/css/ocs-bot.css new file mode 100644 index 0000000000..9b722174ee --- /dev/null +++ b/assets/css/ocs-bot.css @@ -0,0 +1,412 @@ +/* ============================================================================= + OCS Assistant — global chatbot widget for pages.opencodingsociety.com + Dark, glassy design system tuned to the Open Coding Society brand + (deep slate surfaces, electric-blue → cyan accent). Self-contained: + every selector is namespaced under .ocsb- so it can never collide with + site styles. Loads below-the-fold (a fixed launcher button), so there is + no flash-of-unstyled-content even though the stylesheet is linked late. + ============================================================================= */ + +:root { + /* ---- Surfaces ---- */ + --ocsb-bg-fab: linear-gradient(135deg, #2f8fff 0%, #1167d6 100%); + --ocsb-bg-panel: #0f1620; + --ocsb-bg-panel-2: #131c28; + --ocsb-bg-rail: #0b1119; + --ocsb-bg-head: rgba(15, 22, 32, 0.92); + --ocsb-bg-input: #0c131c; + --ocsb-bg-bot: #1a2533; + --ocsb-bg-user: linear-gradient(135deg, #2f8fff 0%, #1f74e0 100%); + --ocsb-bg-chip: rgba(79, 175, 239, 0.10); + --ocsb-bg-chip-hover: rgba(79, 175, 239, 0.20); + --ocsb-bg-code: #060b12; + + /* ---- Text ---- */ + --ocsb-fg: #eef4fb; + --ocsb-fg-muted: #9fb1c4; + --ocsb-fg-faint: #6b7d92; + --ocsb-fg-on-accent: #ffffff; + + /* ---- Accent ---- */ + --ocsb-accent: #4cafef; + --ocsb-accent-2: #38e2c2; /* cyan-green, OCS game accent */ + --ocsb-accent-deep: #1167d6; + --ocsb-accent-soft: rgba(76, 175, 239, 0.16); + --ocsb-glow: rgba(47, 143, 255, 0.45); + + /* ---- Lines ---- */ + --ocsb-border: rgba(127, 165, 204, 0.16); + --ocsb-border-strong: rgba(127, 165, 204, 0.28); + + /* ---- Status ---- */ + --ocsb-ok: #34c759; + --ocsb-warn: #f59e0b; + --ocsb-err: #ff5c6c; + + /* ---- Shape / motion ---- */ + --ocsb-radius-panel: 20px; + --ocsb-radius-msg: 16px; + --ocsb-radius-chip: 999px; + --ocsb-shadow-fab: 0 10px 30px rgba(17, 103, 214, 0.45), 0 2px 8px rgba(0, 0, 0, 0.35); + --ocsb-shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--ocsb-border); + --ocsb-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --ocsb-mono: "JetBrains Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace; + + /* ---- Layering (stay above OCS nav/footer) ---- */ + --ocsb-z-fab: 99990; + --ocsb-z-backdrop: 99994; + --ocsb-z-panel: 99995; +} + +/* Hard reset inside the widget only — protects against inherited site rules. */ +#ocsb-fab, #ocsb-fab *, +#ocsb-panel, #ocsb-panel * { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: var(--ocsb-font); +} + +/* ============================ Launcher (FAB) ============================ */ +#ocsb-fab { + position: fixed; + right: 22px; + bottom: 22px; + z-index: var(--ocsb-z-fab); + display: inline-flex; + align-items: center; + gap: 10px; + padding: 13px 18px 13px 15px; + border: none; + border-radius: var(--ocsb-radius-chip); + background: var(--ocsb-bg-fab); + color: var(--ocsb-fg-on-accent); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: -0.01em; + cursor: pointer; + box-shadow: var(--ocsb-shadow-fab); + transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 320ms ease, opacity 200ms ease; +} +#ocsb-fab:hover { transform: translateY(-2px) scale(1.02); } +#ocsb-fab:active { transform: scale(0.97); } +#ocsb-fab:focus-visible { outline: 3px solid var(--ocsb-accent-2); outline-offset: 3px; } +#ocsb-fab .ocsb-fab-icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.18); +} +#ocsb-fab .ocsb-fab-icon svg { width: 18px; height: 18px; } +#ocsb-fab .ocsb-fab-pulse { + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: 0 0 0 0 var(--ocsb-glow); + animation: ocsb-pulse 2.6s ease-out infinite; + pointer-events: none; +} +@keyframes ocsb-pulse { + 0% { box-shadow: 0 0 0 0 var(--ocsb-glow); } + 70% { box-shadow: 0 0 0 14px rgba(47, 143, 255, 0); } + 100% { box-shadow: 0 0 0 0 rgba(47, 143, 255, 0); } +} +/* Hide FAB while the panel is open */ +body.ocsb-open #ocsb-fab { transform: scale(0); opacity: 0; pointer-events: none; } + +/* ============================ Backdrop ============================ */ +#ocsb-backdrop { + position: fixed; + inset: 0; + z-index: var(--ocsb-z-backdrop); + background: rgba(4, 8, 14, 0.55); + opacity: 0; + visibility: hidden; + transition: opacity 220ms ease, visibility 220ms ease; +} +body.ocsb-open.ocsb-modal #ocsb-backdrop { opacity: 1; visibility: visible; } + +/* ============================ Panel ============================ */ +#ocsb-panel { + position: fixed; + right: 22px; + bottom: 22px; + z-index: var(--ocsb-z-panel); + width: 408px; + height: min(640px, calc(100vh - 44px)); + display: grid; + grid-template-columns: 0fr 1fr; + background: var(--ocsb-bg-panel); + border-radius: var(--ocsb-radius-panel); + box-shadow: var(--ocsb-shadow-panel); + overflow: hidden; + opacity: 0; + visibility: hidden; + transform: translateY(16px) scale(0.98); + transform-origin: bottom right; + transition: opacity 200ms ease, transform 240ms cubic-bezier(0.22, 1, 0.36, 1), + visibility 200ms ease, width 260ms ease, height 260ms ease; + color: var(--ocsb-fg); +} +body.ocsb-open #ocsb-panel { opacity: 1; visibility: visible; transform: translateY(0) scale(1); } +body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 168px 1fr; } + +/* Expanded mode — bigger card */ +body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(720px, calc(100vw - 44px)); height: min(800px, calc(100vh - 44px)); } +body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: 220px 1fr; } + +/* ---- Conversation rail ---- */ +#ocsb-rail { + background: var(--ocsb-bg-rail); + border-right: 1px solid var(--ocsb-border); + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} +.ocsb-rail-head { padding: 12px 10px 8px; } +#ocsb-new { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 9px 10px; + border: 1px solid var(--ocsb-border-strong); + border-radius: 11px; + background: var(--ocsb-bg-chip); + color: var(--ocsb-fg); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease; +} +#ocsb-new:hover { background: var(--ocsb-bg-chip-hover); border-color: var(--ocsb-accent); } +#ocsb-new svg { width: 15px; height: 15px; } +.ocsb-rail-search { padding: 2px 10px 8px; } +.ocsb-rail-search input { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--ocsb-border); + border-radius: 9px; + background: var(--ocsb-bg-input); + color: var(--ocsb-fg); + font-size: 0.78rem; +} +.ocsb-rail-search input::placeholder { color: var(--ocsb-fg-faint); } +.ocsb-rail-search input:focus { outline: none; border-color: var(--ocsb-accent); } +#ocsb-convos { list-style: none; flex: 1; overflow-y: auto; padding: 2px 8px 10px; } +#ocsb-convos li { margin-bottom: 3px; } +.ocsb-convo { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 9px; + border: 1px solid transparent; + border-radius: 9px; + background: transparent; + color: var(--ocsb-fg-muted); + font-size: 0.8rem; + text-align: left; + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} +.ocsb-convo:hover { background: rgba(255, 255, 255, 0.04); color: var(--ocsb-fg); } +.ocsb-convo.is-active { background: var(--ocsb-accent-soft); color: var(--ocsb-fg); border-color: var(--ocsb-border-strong); } +.ocsb-convo-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ocsb-convo-del { + flex: none; display: none; width: 20px; height: 20px; border: none; border-radius: 6px; + background: transparent; color: var(--ocsb-fg-faint); cursor: pointer; font-size: 0.9rem; line-height: 1; +} +.ocsb-convo:hover .ocsb-convo-del { display: grid; place-items: center; } +.ocsb-convo-del:hover { color: var(--ocsb-err); background: rgba(255, 92, 108, 0.12); } +.ocsb-rail-empty { padding: 12px 12px; color: var(--ocsb-fg-faint); font-size: 0.74rem; line-height: 1.5; } + +/* ---- Main column ---- */ +.ocsb-main { display: flex; flex-direction: column; min-width: 0; background: var(--ocsb-bg-panel); } + +/* Header */ +#ocsb-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 12px 11px; + background: var(--ocsb-bg-head); + border-bottom: 1px solid var(--ocsb-border); + backdrop-filter: blur(6px); +} +.ocsb-head-avatar { + flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; + background: var(--ocsb-bg-fab); color: #fff; box-shadow: 0 4px 12px rgba(17, 103, 214, 0.4); +} +.ocsb-head-avatar svg { width: 20px; height: 20px; } +.ocsb-head-meta { flex: 1; min-width: 0; } +.ocsb-head-meta h2 { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--ocsb-fg); } +.ocsb-status { display: flex; align-items: center; gap: 6px; font-size: 0.72rem; color: var(--ocsb-fg-muted); margin-top: 1px; } +.ocsb-status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-ok); box-shadow: 0 0 0 0 rgba(52, 199, 89, 0.5); animation: ocsb-dot 2.4s ease-out infinite; } +@keyframes ocsb-dot { 0% { box-shadow: 0 0 0 0 rgba(52,199,89,0.5);} 70%{ box-shadow:0 0 0 6px rgba(52,199,89,0);} 100%{box-shadow:0 0 0 0 rgba(52,199,89,0);} } +.ocsb-head-actions { display: flex; align-items: center; gap: 2px; } +.ocsb-icon-btn { + display: grid; place-items: center; width: 32px; height: 32px; border: none; border-radius: 9px; + background: transparent; color: var(--ocsb-fg-muted); cursor: pointer; transition: background 140ms ease, color 140ms ease; +} +.ocsb-icon-btn:hover { background: rgba(255, 255, 255, 0.06); color: var(--ocsb-fg); } +.ocsb-icon-btn:focus-visible { outline: 2px solid var(--ocsb-accent); outline-offset: 1px; } +.ocsb-icon-btn svg { width: 17px; height: 17px; } +#ocsb-rail-toggle { margin-right: -2px; } + +/* Messages */ +#ocsb-messages { flex: 1; overflow-y: auto; padding: 16px 14px 6px; scroll-behavior: smooth; } +#ocsb-messages::-webkit-scrollbar, #ocsb-convos::-webkit-scrollbar { width: 8px; } +#ocsb-messages::-webkit-scrollbar-thumb, #ocsb-convos::-webkit-scrollbar-thumb { background: var(--ocsb-border-strong); border-radius: 8px; } + +.ocsb-msg { display: flex; gap: 9px; margin-bottom: 14px; animation: ocsb-msg-in 260ms cubic-bezier(0.22,1,0.36,1); } +@keyframes ocsb-msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.ocsb-msg-avatar { flex: none; width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; font-size: 0.8rem; } +.ocsb-msg.bot .ocsb-msg-avatar { background: var(--ocsb-accent-soft); color: var(--ocsb-accent); } +.ocsb-msg.bot .ocsb-msg-avatar svg { width: 16px; height: 16px; } +.ocsb-msg.user { flex-direction: row-reverse; } +.ocsb-msg.user .ocsb-msg-avatar { background: rgba(255,255,255,0.10); color: var(--ocsb-fg-muted); } +.ocsb-bubble { + max-width: 80%; + padding: 10px 13px; + border-radius: var(--ocsb-radius-msg); + font-size: 0.875rem; + line-height: 1.55; + word-wrap: break-word; + overflow-wrap: anywhere; +} +.ocsb-msg.bot .ocsb-bubble { background: var(--ocsb-bg-bot); color: var(--ocsb-fg); border-top-left-radius: 5px; } +.ocsb-msg.user .ocsb-bubble { background: var(--ocsb-bg-user); color: #fff; border-top-right-radius: 5px; } + +/* Markdown inside bubbles */ +.ocsb-bubble p { margin: 0 0 8px; } .ocsb-bubble p:last-child { margin-bottom: 0; } +.ocsb-bubble h3, .ocsb-bubble h4 { margin: 6px 0 5px; font-size: 0.92rem; font-weight: 700; } +.ocsb-bubble ul, .ocsb-bubble ol { margin: 4px 0 8px; padding-left: 20px; } +.ocsb-bubble li { margin: 2px 0; } +.ocsb-bubble a { color: var(--ocsb-accent); text-decoration: underline; text-underline-offset: 2px; } +.ocsb-msg.user .ocsb-bubble a { color: #eaf3ff; } +.ocsb-bubble code { font-family: var(--ocsb-mono); font-size: 0.82em; background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 5px; } +.ocsb-bubble pre { position: relative; background: var(--ocsb-bg-code); border: 1px solid var(--ocsb-border); border-radius: 10px; padding: 11px 12px; margin: 7px 0; overflow-x: auto; } +.ocsb-bubble pre code { background: none; padding: 0; font-size: 0.8rem; line-height: 1.5; color: #d7e3f2; } +.ocsb-bubble pre .ocsb-copy { + position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 0.68rem; font-weight: 600; + border: 1px solid var(--ocsb-border-strong); border-radius: 6px; background: rgba(255,255,255,0.05); + color: var(--ocsb-fg-muted); cursor: pointer; opacity: 0; transition: opacity 140ms ease; +} +.ocsb-bubble pre:hover .ocsb-copy { opacity: 1; } +.ocsb-bubble pre .ocsb-copy:hover { color: var(--ocsb-fg); border-color: var(--ocsb-accent); } +.ocsb-bubble blockquote { border-left: 3px solid var(--ocsb-accent); padding-left: 10px; margin: 6px 0; color: var(--ocsb-fg-muted); } + +/* Navigation action chips under a bot message */ +.ocsb-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 9px; } +.ocsb-nav-chip { + display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; + border: 1px solid var(--ocsb-border-strong); border-radius: var(--ocsb-radius-chip); + background: var(--ocsb-bg-chip); color: var(--ocsb-accent); font-size: 0.78rem; font-weight: 600; + cursor: pointer; transition: background 140ms ease, transform 120ms ease, border-color 140ms ease; +} +.ocsb-nav-chip:hover { background: var(--ocsb-bg-chip-hover); border-color: var(--ocsb-accent); transform: translateY(-1px); } +.ocsb-nav-chip svg { width: 13px; height: 13px; } + +/* Typing indicator */ +.ocsb-typing { display: inline-flex; gap: 4px; align-items: center; padding: 4px 2px; } +.ocsb-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-fg-faint); animation: ocsb-typing 1.2s infinite ease-in-out; } +.ocsb-typing span:nth-child(2) { animation-delay: 0.18s; } +.ocsb-typing span:nth-child(3) { animation-delay: 0.36s; } +@keyframes ocsb-typing { 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-5px); opacity: 1; } } + +/* Welcome / empty state */ +#ocsb-welcome { padding: 20px 16px 8px; } +.ocsb-welcome-hero { text-align: center; margin-bottom: 16px; } +.ocsb-welcome-badge { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 16px; background: var(--ocsb-bg-fab); color: #fff; margin-bottom: 12px; box-shadow: 0 8px 24px rgba(17,103,214,0.4); } +.ocsb-welcome-badge svg { width: 28px; height: 28px; } +.ocsb-welcome-hero h3 { font-size: 1.18rem; font-weight: 800; letter-spacing: -0.02em; color: var(--ocsb-fg); } +.ocsb-welcome-hero p { font-size: 0.85rem; color: var(--ocsb-fg-muted); margin-top: 5px; line-height: 1.5; } +.ocsb-suggest-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ocsb-fg-faint); margin: 6px 4px 8px; } +#ocsb-suggestions { display: flex; flex-direction: column; gap: 8px; } +.ocsb-suggestion { + display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 13px; text-align: left; + border: 1px solid var(--ocsb-border); border-radius: 12px; background: var(--ocsb-bg-panel-2); + color: var(--ocsb-fg); font-size: 0.83rem; cursor: pointer; transition: border-color 150ms ease, background 150ms ease, transform 120ms ease; +} +.ocsb-suggestion:hover { border-color: var(--ocsb-accent); background: var(--ocsb-bg-bot); transform: translateY(-1px); } +.ocsb-suggestion .ocsb-suggestion-ico { flex: none; font-size: 1.05rem; } +.ocsb-suggestion span.ocsb-suggestion-txt { flex: 1; } + +/* Follow-up chips above composer */ +#ocsb-followups { display: flex; flex-wrap: wrap; gap: 7px; padding: 0 14px 8px; } +#ocsb-followups[hidden] { display: none; } +.ocsb-followup { + padding: 6px 11px; border: 1px solid var(--ocsb-border); border-radius: var(--ocsb-radius-chip); + background: transparent; color: var(--ocsb-fg-muted); font-size: 0.76rem; cursor: pointer; transition: all 140ms ease; +} +.ocsb-followup:hover { color: var(--ocsb-fg); border-color: var(--ocsb-accent); background: var(--ocsb-bg-chip); } + +/* Composer */ +#ocsb-form { padding: 10px 12px 12px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-panel); } +.ocsb-composer-row { display: flex; align-items: flex-end; gap: 8px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border-strong); border-radius: 14px; padding: 6px 6px 6px 12px; transition: border-color 150ms ease, box-shadow 150ms ease; } +.ocsb-composer-row:focus-within { border-color: var(--ocsb-accent); box-shadow: 0 0 0 3px var(--ocsb-accent-soft); } +#ocsb-input { flex: 1; resize: none; max-height: 120px; border: none; background: none; color: var(--ocsb-fg); font-size: 0.875rem; line-height: 1.45; padding: 6px 0; outline: none; } +#ocsb-input::placeholder { color: var(--ocsb-fg-faint); } +#ocsb-send { + flex: none; display: grid; place-items: center; width: 36px; height: 36px; border: none; border-radius: 10px; + background: var(--ocsb-bg-fab); color: #fff; cursor: pointer; transition: transform 130ms ease, opacity 150ms ease; +} +#ocsb-send:hover:not(:disabled) { transform: scale(1.06); } +#ocsb-send:disabled { opacity: 0.4; cursor: not-allowed; } +#ocsb-send svg { width: 17px; height: 17px; } +.ocsb-composer-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 7px; padding: 0 2px; } +.ocsb-composer-hint { font-size: 0.68rem; color: var(--ocsb-fg-faint); } +.ocsb-composer-hint b { color: var(--ocsb-fg-muted); font-weight: 600; } + +/* Settings drawer */ +#ocsb-settings { padding: 14px 16px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-rail); } +#ocsb-settings[hidden] { display: none; } +.ocsb-set-row { margin-bottom: 14px; } +.ocsb-set-row:last-child { margin-bottom: 0; } +.ocsb-set-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ocsb-fg-faint); margin-bottom: 7px; } +.ocsb-segmented { display: flex; gap: 4px; background: var(--ocsb-bg-input); padding: 4px; border-radius: 10px; border: 1px solid var(--ocsb-border); } +.ocsb-segmented button { flex: 1; padding: 7px 6px; border: none; border-radius: 7px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: background 140ms ease, color 140ms ease; } +.ocsb-segmented button.is-active { background: var(--ocsb-accent-soft); color: var(--ocsb-accent); } +.ocsb-set-account { display: flex; align-items: center; gap: 9px; padding: 10px 11px; border-radius: 10px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border); font-size: 0.78rem; color: var(--ocsb-fg-muted); } +.ocsb-set-account .ocsb-dot2 { width: 8px; height: 8px; border-radius: 50%; flex: none; } +.ocsb-danger-btn { width: 100%; padding: 9px; border: 1px solid rgba(255,92,108,0.35); border-radius: 10px; background: rgba(255,92,108,0.08); color: var(--ocsb-err); font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: background 140ms ease; } +.ocsb-danger-btn:hover { background: rgba(255,92,108,0.16); } + +/* Error / toast line */ +.ocsb-error { margin: 0 14px 10px; padding: 9px 12px; border-radius: 10px; background: rgba(255,92,108,0.10); border: 1px solid rgba(255,92,108,0.30); color: #ffd5da; font-size: 0.78rem; } + +/* ============================ Mobile / responsive ============================ */ +@media (max-width: 560px) { + #ocsb-fab .ocsb-fab-label { display: none; } + #ocsb-fab { padding: 13px; right: 16px; bottom: 16px; } + body.ocsb-open #ocsb-panel, + body.ocsb-open.ocsb-expanded #ocsb-panel { + right: 0; bottom: 0; left: 0; top: 0; + width: 100vw; height: 100vh; height: 100dvh; + border-radius: 0; + grid-template-columns: 0fr 1fr; + } + /* On phones the rail overlays instead of pushing */ + body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 0fr 1fr; } + body.ocsb-open.ocsb-rail-open #ocsb-rail { + position: absolute; top: 0; bottom: 0; left: 0; width: 76%; max-width: 280px; z-index: 5; + box-shadow: 24px 0 60px rgba(0,0,0,0.6); + } + .ocsb-bubble { max-width: 86%; } +} + +/* Light-site fallback: site is dark, but if ever rendered on a light bg, + the panel keeps its own dark surfaces (intentional, self-contained). */ + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + #ocsb-fab, #ocsb-panel, .ocsb-msg, .ocsb-nav-chip, #ocsb-send, .ocsb-suggestion { transition: none !important; animation: none !important; } + .ocsb-fab-pulse, .ocsb-status-dot, .ocsb-typing span { animation: none !important; } + #ocsb-messages { scroll-behavior: auto; } +} diff --git a/assets/js/ocs-bot/api.js b/assets/js/ocs-bot/api.js new file mode 100644 index 0000000000..633a6616a4 --- /dev/null +++ b/assets/js/ocs-bot/api.js @@ -0,0 +1,150 @@ +// assets/js/ocs-bot/api.js +// ----------------------------------------------------------------------------- +// Networking layer. Three concerns, all fail-safe: +// 1. chat() — POST to the Flask Groq proxy, returns assistant text. +// 2. whoAmI() — detect the logged-in OCS user (for per-user chat saving). +// 3. remote*() — OPTIONAL server-side conversation sync. Every call is +// best-effort: if the /api/chat endpoints aren't deployed, +// or the user is a guest, these return null/false and the +// caller falls back to localStorage. They never throw. +// ----------------------------------------------------------------------------- + +import { + GROQ_ENDPOINT, WHOAMI_ENDPOINT, CHAT_API_BASE, + DEFAULT_MODEL, FETCH_TIMEOUT, ENABLE_BACKEND_SYNC, +} from './config.js'; + +function withTimeout(promise, ms) { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error('timeout')), ms); + promise.then( + (v) => { clearTimeout(t); resolve(v); }, + (e) => { clearTimeout(t); reject(e); } + ); + }); +} + +// ─── Chat completion (Groq via Flask) ──────────────────────────────────────── +// `messages` is an OpenAI-style array: [{role:'system'|'user'|'assistant', content}] +export async function chat({ messages, model = DEFAULT_MODEL, temperature = 0.6, signal }) { + const res = await withTimeout( + fetch(GROQ_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + // No credentials needed — the proxy is public and holds the key server-side. + body: JSON.stringify({ messages, model, temperature, max_tokens: 1200 }), + signal, + }), + FETCH_TIMEOUT + ); + + let data = {}; + try { data = await res.json(); } catch (_e) { /* ignore */ } + + if (!res.ok || data.success === false) { + const detail = `${data.error || ''} ${data.details || ''}`.toLowerCase(); + if (res.status === 429 || detail.includes('rate limit')) { + throw new Error("I'm getting a lot of questions right now — give me a few seconds and try again."); + } + if (res.status === 500 && detail.includes('groq_api_key')) { + throw new Error('The assistant isn’t configured on the server yet (missing API key). Please let an OCS admin know.'); + } + const msg = data.error || data.message || `The assistant is unavailable right now (HTTP ${res.status}). Please try again.`; + throw new Error(msg); + } + // Enhanced endpoint -> {response}; original endpoint -> OpenAI shape. + const text = + (typeof data.response === 'string' && data.response) || + data?.choices?.[0]?.message?.content || + ''; + if (!text) throw new Error('The assistant returned an empty reply. Try rephrasing.'); + return text.trim(); +} + +// ─── Who am I (login detection) ────────────────────────────────────────────── +// Returns a normalized user object, or null if not signed in / backend down. +export async function whoAmI() { + try { + const res = await withTimeout( + fetch(WHOAMI_ENDPOINT, { + method: 'GET', + credentials: 'include', // send the JWT cookie + headers: { 'Content-Type': 'application/json', 'X-Origin': 'client' }, + }), + 12000 + ); + if (!res.ok) return null; // 401 = guest + const u = await res.json(); + if (!u || (u.id == null && !u.uid && !u.email)) return null; + return { + id: u.id != null ? String(u.id) : (u.uid || u.email), + uid: u.uid || null, + name: u.name || u.uid || 'there', + firstName: (u.name || '').trim().split(/\s+/)[0] || (u.uid || ''), + email: u.email || null, + role: u.role || null, + isAdmin: !!u.is_admin, + isTeacher: !!u.is_teacher, + }; + } catch (_e) { + return null; + } +} + +// ─── Optional server-side conversation sync ────────────────────────────────── +// All of these resolve to a safe value on any failure (never throw). + +async function safeJson(promise) { + try { + const res = await withTimeout(promise, 12000); + if (!res.ok) return { ok: false, status: res.status }; + const data = await res.json().catch(() => null); + return { ok: true, data }; + } catch (_e) { + return { ok: false, status: 0 }; + } +} + +const credHeaders = { 'Content-Type': 'application/json', 'X-Origin': 'client' }; +const credInit = (method, body) => ({ + method, + credentials: 'include', + headers: credHeaders, + body: body ? JSON.stringify(body) : undefined, +}); + +export async function remoteList() { + if (!ENABLE_BACKEND_SYNC) return null; + const r = await safeJson(fetch(CHAT_API_BASE, credInit('GET'))); + return r.ok && Array.isArray(r.data) ? r.data : null; +} + +export async function remoteGet(id) { + if (!ENABLE_BACKEND_SYNC) return null; + const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}`, credInit('GET'))); + return r.ok ? r.data : null; +} + +export async function remoteCreate(title) { + if (!ENABLE_BACKEND_SYNC) return null; + const r = await safeJson(fetch(CHAT_API_BASE, credInit('POST', { title }))); + return r.ok ? r.data : null; +} + +export async function remoteAddMessage(id, role, content) { + if (!ENABLE_BACKEND_SYNC) return null; + const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}/messages`, credInit('POST', { role, content }))); + return r.ok ? r.data : null; +} + +export async function remoteRename(id, title) { + if (!ENABLE_BACKEND_SYNC) return false; + const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}`, credInit('PUT', { title }))); + return r.ok; +} + +export async function remoteDelete(id) { + if (!ENABLE_BACKEND_SYNC) return false; + const r = await safeJson(fetch(`${CHAT_API_BASE}/${id}`, credInit('DELETE'))); + return r.ok; +} diff --git a/assets/js/ocs-bot/config.js b/assets/js/ocs-bot/config.js new file mode 100644 index 0000000000..349a4fa5b7 --- /dev/null +++ b/assets/js/ocs-bot/config.js @@ -0,0 +1,42 @@ +// assets/js/ocs-bot/config.js +// ----------------------------------------------------------------------------- +// Resolves where the backend lives and which endpoints to use. Mirrors the +// site's own assets/js/api/config.js convention (localhost -> :8587, else the +// production Flask host) so the chatbot talks to the SAME backend as the rest +// of pages.opencodingsociety.com. The Groq API key never touches the client — +// the Flask `/api/groq/chat` endpoint holds it server-side. +// +// Anything here can be overridden by defining `window.OCS_BOT_CONFIG` before +// this module loads, e.g. for local testing against production: +// window.OCS_BOT_CONFIG = { apiBase: 'https://flask.opencodingsociety.com' }; +// ----------------------------------------------------------------------------- + +const overrides = (typeof window !== 'undefined' && window.OCS_BOT_CONFIG) || {}; + +function resolveApiBase() { + if (overrides.apiBase) return overrides.apiBase.replace(/\/$/, ''); + const host = (typeof location !== 'undefined' && location.hostname) || ''; + if (host === 'localhost' || host === '127.0.0.1') return 'http://localhost:8587'; + return 'https://flask.opencodingsociety.com'; +} + +export const API_BASE = resolveApiBase(); + +// Groq chat-completions proxy (returns { success, response, model, usage }). +export const GROQ_ENDPOINT = `${API_BASE}/api/groq/chat`; + +// "Who am I" — returns the logged-in user (needs the JWT cookie). 401 = guest. +export const WHOAMI_ENDPOINT = `${API_BASE}/api/id`; + +// Optional server-side chat history (graceful: silently unused if not deployed). +export const CHAT_API_BASE = `${API_BASE}/api/chat`; + +// Default + available models (kept in sync with the Flask groq_api). +export const DEFAULT_MODEL = overrides.model || 'llama-3.3-70b-versatile'; +export const FAST_MODEL = 'llama-3.1-8b-instant'; + +// Network timeout for completions (ms). +export const FETCH_TIMEOUT = 45000; + +// Allow disabling the optional backend sync entirely. +export const ENABLE_BACKEND_SYNC = overrides.backendSync !== false; diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js new file mode 100644 index 0000000000..5b7f53bd90 --- /dev/null +++ b/assets/js/ocs-bot/index.js @@ -0,0 +1,541 @@ +// assets/js/ocs-bot/index.js +// ----------------------------------------------------------------------------- +// OCS Assistant controller. Boots the widget, wires every interaction, runs the +// chat turn (system prompt -> Groq via Flask -> typewriter render -> nav chips), +// and persists conversations per signed-in user (with optional backend sync). +// ----------------------------------------------------------------------------- + +import * as api from './api.js'; +import * as store from './store.js'; +import { buildSystemPrompt } from './prompt.js'; +import { renderMarkdown } from './render.js'; +import { parseActions, hrefFor } from './tools.js'; +import { starterSuggestions } from './suggestions.js'; +import { NAV_INDEX } from './knowledge.js'; +import { DEFAULT_MODEL } from './config.js'; + +const $ = (id) => document.getElementById(id); +const HISTORY_TURNS = 16; // messages of context sent to the model + +const BOT_AVATAR = + ''; +const ARROW = + ''; + +const bot = { + el: {}, + user: null, + generating: false, + abort: null, + booted: false, +}; + +// ─── Boot ───────────────────────────────────────────────────────────────── +function boot() { + if (bot.booted) return; + const el = bot.el; + [ + 'ocsb-fab', 'ocsb-backdrop', 'ocsb-panel', 'ocsb-rail', 'ocsb-new', + 'ocsb-rail-search-input', 'ocsb-convos', 'ocsb-rail-toggle', 'ocsb-status-text', + 'ocsb-settings-btn', 'ocsb-expand', 'ocsb-close', 'ocsb-error', 'ocsb-messages', + 'ocsb-welcome', 'ocsb-welcome-greeting', 'ocsb-suggestions', 'ocsb-followups', + 'ocsb-settings', 'ocsb-set-style', 'ocsb-set-model', 'ocsb-account', + 'ocsb-account-text', 'ocsb-clear', 'ocsb-form', 'ocsb-input', 'ocsb-send', + ].forEach((id) => { el[id] = $(id); }); + if (!el['ocsb-fab'] || !el['ocsb-panel']) return; // markup missing — bail safely + bot.booted = true; + + wireEvents(); + applyPrefs(); + renderSuggestions(); + ensureActiveConversation(); + renderRail(); + renderActiveConversation(); + + // Detect the signed-in user (async, non-blocking). + detectUser(); +} + +// ─── User / scope ──────────────────────────────────────────────────────── +async function detectUser() { + const user = await api.whoAmI(); + bot.user = user; + // Re-scope storage to this user and re-render (migrating the current view). + store.setScope(user ? user.id : 'guest'); + ensureActiveConversation(); + renderRail(); + renderActiveConversation(); + updateAccountUI(); + if (user) { + bot.el['ocsb-welcome-greeting'].textContent = `Hey ${user.firstName}! I'm the OCS Assistant 👋`; + syncFromRemote(); // pull server-side history if available + } +} + +function updateAccountUI() { + const dot = bot.el['ocsb-account'].querySelector('.ocsb-dot2'); + if (bot.user) { + bot.el['ocsb-account-text'].innerHTML = `Signed in as ${escapeText(bot.user.name)} · chats saved to your account`; + if (dot) dot.style.background = 'var(--ocsb-ok)'; + } else { + bot.el['ocsb-account-text'].innerHTML = `Browsing as guest. Log in to save chats across devices.`; + if (dot) dot.style.background = 'var(--ocsb-fg-faint)'; + } +} + +// ─── Events ───────────────────────────────────────────────────────────────── +function wireEvents() { + const el = bot.el; + el['ocsb-fab'].addEventListener('click', open); + el['ocsb-close'].addEventListener('click', close); + el['ocsb-backdrop'].addEventListener('click', close); + el['ocsb-expand'].addEventListener('click', toggleExpand); + el['ocsb-rail-toggle'].addEventListener('click', toggleRail); + el['ocsb-settings-btn'].addEventListener('click', toggleSettings); + el['ocsb-new'].addEventListener('click', () => { newChat(); el['ocsb-input'].focus(); }); + el['ocsb-clear'].addEventListener('click', clearCurrent); + + el['ocsb-form'].addEventListener('submit', (e) => { e.preventDefault(); submit(); }); + el['ocsb-input'].addEventListener('input', onInput); + el['ocsb-input'].addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } + }); + + el['ocsb-rail-search-input'].addEventListener('input', () => renderRail(el['ocsb-rail-search-input'].value)); + + // Settings segmented controls + el['ocsb-set-style'].addEventListener('click', (e) => segmented(e, 'set-style', 'style', 'data-style')); + el['ocsb-set-model'].addEventListener('click', (e) => segmented(e, 'set-model', 'model', 'data-model')); + + // Delegated clicks: suggestions, nav chips, copy buttons, convo items, followups + document.addEventListener('click', delegatedClick); + + // Esc closes; Cmd/Ctrl-K opens + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && document.body.classList.contains('ocsb-open')) { + if (!el['ocsb-settings'].hidden) { toggleSettings(); return; } + close(); + } + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + document.body.classList.contains('ocsb-open') ? el['ocsb-input'].focus() : open(); + } + }); +} + +function segmented(e, group, pref, attr) { + const btn = e.target.closest('button[' + attr + ']'); + if (!btn) return; + const value = btn.getAttribute(attr); + store.setPref(pref, value); + bot.el['ocsb-' + group].querySelectorAll('button').forEach((b) => b.classList.toggle('is-active', b === btn)); +} + +function delegatedClick(e) { + // Suggestion chip + const sugg = e.target.closest('.ocsb-suggestion'); + if (sugg && bot.el['ocsb-panel'].contains(sugg)) { bot.el['ocsb-input'].value = sugg.dataset.text || sugg.textContent.trim(); onInput(); submit(); return; } + // Follow-up chip + const fu = e.target.closest('.ocsb-followup'); + if (fu && bot.el['ocsb-panel'].contains(fu)) { bot.el['ocsb-input'].value = fu.dataset.text || fu.textContent.trim(); onInput(); submit(); return; } + // Navigation chip + const nav = e.target.closest('.ocsb-nav-chip'); + if (nav && bot.el['ocsb-panel'].contains(nav)) { const href = nav.getAttribute('data-href'); if (href) window.location.href = href; return; } + // Copy code button + const copy = e.target.closest('.ocsb-copy'); + if (copy) { const code = copy.parentElement.querySelector('code'); copyText(code ? code.textContent : '', copy); return; } + // Conversation row (switch) / delete + const del = e.target.closest('.ocsb-convo-del'); + if (del) { e.stopPropagation(); deleteConvo(del.closest('.ocsb-convo').dataset.id); return; } + const row = e.target.closest('.ocsb-convo'); + if (row && bot.el['ocsb-rail'].contains(row)) { switchConvo(row.dataset.id); return; } +} + +// ─── Open / close / layout ─────────────────────────────────────────────── +function open() { + document.body.classList.add('ocsb-open'); + if (window.innerWidth <= 560) document.body.classList.add('ocsb-modal'); + bot.el['ocsb-fab'].setAttribute('aria-expanded', 'true'); + bot.el['ocsb-panel'].setAttribute('aria-hidden', 'false'); + bot.el['ocsb-panel'].setAttribute('aria-modal', window.innerWidth <= 560 ? 'true' : 'false'); + setTimeout(() => bot.el['ocsb-input'].focus(), 240); +} +function close() { + document.body.classList.remove('ocsb-open', 'ocsb-modal'); + bot.el['ocsb-fab'].setAttribute('aria-expanded', 'false'); + bot.el['ocsb-panel'].setAttribute('aria-hidden', 'true'); + bot.el['ocsb-settings'].hidden = true; + bot.el['ocsb-settings-btn'].setAttribute('aria-pressed', 'false'); + bot.el['ocsb-fab'].focus(); +} +function toggleExpand() { + const on = document.body.classList.toggle('ocsb-expanded'); + document.body.classList.toggle('ocsb-modal', on || window.innerWidth <= 560); + bot.el['ocsb-expand'].setAttribute('aria-pressed', String(on)); +} +function toggleRail() { + const on = document.body.classList.toggle('ocsb-rail-open'); + store.setPref('railOpen', on); + bot.el['ocsb-rail-toggle'].setAttribute('aria-pressed', String(on)); +} +function toggleSettings() { + const open = bot.el['ocsb-settings'].hidden; + bot.el['ocsb-settings'].hidden = !open; + bot.el['ocsb-settings-btn'].setAttribute('aria-pressed', String(open)); + if (open) updateAccountUI(); +} + +function applyPrefs() { + const prefs = store.getPrefs(); + if (prefs.railOpen && window.innerWidth > 560) { + document.body.classList.add('ocsb-rail-open'); + bot.el['ocsb-rail-toggle'].setAttribute('aria-pressed', 'true'); + } + const markActive = (groupId, attr, value) => + bot.el[groupId].querySelectorAll('button').forEach((b) => b.classList.toggle('is-active', b.getAttribute(attr) === value)); + markActive('ocsb-set-style', 'data-style', prefs.style); + markActive('ocsb-set-model', 'data-model', prefs.model); +} + +// ─── Composer ─────────────────────────────────────────────────────────────── +function onInput() { + const ta = bot.el['ocsb-input']; + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; + bot.el['ocsb-send'].disabled = bot.generating || !ta.value.trim(); +} + +function ensureActiveConversation() { + let id = store.getActiveId(); + if (!id || !store.getConversation(id)) { + const existing = store.listConversations(); + if (existing.length) { id = existing[0].id; store.setActiveId(id); } + else { id = store.createConversation().id; } + } + return id; +} + +function newChat() { + // Reuse an empty "New chat" if one already exists at the top. + const list = store.listConversations(); + const empty = list.find((c) => c.messages.length === 0); + const id = empty ? empty.id : store.createConversation().id; + store.setActiveId(id); + renderRail(); + renderActiveConversation(); + hideError(); +} + +function switchConvo(id) { + store.setActiveId(id); + renderRail(); + renderActiveConversation(); + if (window.innerWidth <= 560) document.body.classList.remove('ocsb-rail-open'); +} + +function deleteConvo(id) { + store.deleteConversation(id); + ensureActiveConversation(); + renderRail(); + renderActiveConversation(); +} + +function clearCurrent() { + const id = store.getActiveId(); + if (id) store.clearMessages(id); + bot.el['ocsb-settings'].hidden = true; + bot.el['ocsb-settings-btn'].setAttribute('aria-pressed', 'false'); + renderRail(); + renderActiveConversation(); +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── +function renderSuggestions() { + const wrap = bot.el['ocsb-suggestions']; + wrap.innerHTML = ''; + starterSuggestions(bot.user).forEach((s) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'ocsb-suggestion'; + b.dataset.text = s.text; + b.innerHTML = `${escapeText(s.text)}`; + wrap.appendChild(b); + }); +} + +function renderRail(query) { + const list = query ? store.searchConversations(query) : store.listConversations(); + const activeId = store.getActiveId(); + const ul = bot.el['ocsb-convos']; + ul.innerHTML = ''; + if (!list.length) { + ul.innerHTML = '
  • No conversations yet. Your chats will appear here.
  • '; + return; + } + list.forEach((c) => { + const li = document.createElement('li'); + const title = c.title && c.title !== 'New chat' ? c.title : (c.messages[0]?.content?.slice(0, 40) || 'New chat'); + li.innerHTML = ` +
    + ${escapeText(title)} + +
    `; + ul.appendChild(li); + }); +} + +function renderActiveConversation() { + const id = store.getActiveId(); + const convo = id ? store.getConversation(id) : null; + const msgsEl = bot.el['ocsb-messages']; + // Remove rendered message nodes (keep the welcome node). + msgsEl.querySelectorAll('.ocsb-msg').forEach((n) => n.remove()); + bot.el['ocsb-followups'].hidden = true; + bot.el['ocsb-followups'].innerHTML = ''; + + const hasMsgs = convo && convo.messages.length > 0; + bot.el['ocsb-welcome'].hidden = hasMsgs; + bot.el['ocsb-status-text'].textContent = 'Ready to help'; + + if (hasMsgs) { + convo.messages.forEach((m) => { + if (m.role === 'user') addUserBubble(m.content); + else addBotBubble(m.content); + }); + scrollToBottom(); + } +} + +function addUserBubble(text) { + const node = document.createElement('div'); + node.className = 'ocsb-msg user'; + node.innerHTML = `
    `; + node.querySelector('.ocsb-bubble').textContent = text; + bot.el['ocsb-messages'].appendChild(node); + return node; +} + +function addBotBubble(text) { + const node = document.createElement('div'); + node.className = 'ocsb-msg bot'; + node.innerHTML = `
    `; + const bubble = node.querySelector('.ocsb-bubble'); + const { clean, actions } = parseActions(text); + bubble.innerHTML = renderMarkdown(clean); + appendActionChips(bubble, actions); + bot.el['ocsb-messages'].appendChild(node); + return node; +} + +function appendActionChips(bubble, actions) { + if (!actions || !actions.length) return; + const wrap = document.createElement('div'); + wrap.className = 'ocsb-actions'; + actions.forEach((a) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'ocsb-nav-chip'; + chip.setAttribute('data-href', hrefFor(a.path)); + chip.innerHTML = `${escapeText(a.label)} ${ARROW}`; + wrap.appendChild(chip); + }); + bubble.appendChild(wrap); +} + +// ─── The chat turn ─────────────────────────────────────────────────────────── +async function submit() { + if (bot.generating) return; + const ta = bot.el['ocsb-input']; + const text = ta.value.trim(); + if (!text) return; + hideError(); + + const convoId = ensureActiveConversation(); + + // 1) user message + ta.value = ''; + onInput(); + bot.el['ocsb-welcome'].hidden = true; + addUserBubble(text); + scrollToBottom(); + store.appendMessage(convoId, { role: 'user', content: text }); + renderRail(); + remoteSyncMessage(convoId, 'user', text); + + // 2) typing indicator + bot.generating = true; + bot.el['ocsb-send'].disabled = true; + bot.el['ocsb-status-text'].textContent = 'Thinking…'; + const typingNode = addTyping(); + scrollToBottom(); + + // 3) build context + call model + const prefs = store.getPrefs(); + const convo = store.getConversation(convoId); + const history = convo.messages.slice(-HISTORY_TURNS).map((m) => ({ role: m.role, content: m.content })); + const messages = [{ role: 'system', content: buildSystemPrompt({ user: bot.user, prefs }) }, ...history]; + + bot.abort = new AbortController(); + let answer = ''; + try { + answer = await api.chat({ messages, model: prefs.model || DEFAULT_MODEL, signal: bot.abort.signal }); + } catch (err) { + typingNode.remove(); + finishGenerating(); + showError(err && err.message ? err.message : 'Something went wrong reaching the assistant.'); + return; + } + + // 4) render with typewriter, then persist + typingNode.remove(); + const node = addBotBubbleStreaming(); + await typewriter(node.querySelector('.ocsb-bubble'), answer); + + store.appendMessage(convoId, { role: 'assistant', content: answer }); + remoteSyncMessage(convoId, 'assistant', answer); + renderRail(); + renderFollowups(text); + finishGenerating(); +} + +function finishGenerating() { + bot.generating = false; + bot.abort = null; + bot.el['ocsb-status-text'].textContent = 'Ready to help'; + onInput(); +} + +function addTyping() { + const node = document.createElement('div'); + node.className = 'ocsb-msg bot'; + node.innerHTML = `
    `; + bot.el['ocsb-messages'].appendChild(node); + return node; +} + +function addBotBubbleStreaming() { + const node = document.createElement('div'); + node.className = 'ocsb-msg bot'; + node.innerHTML = `
    `; + bot.el['ocsb-messages'].appendChild(node); + return node; +} + +// Reveal text word-by-word, then swap in fully rendered markdown + nav chips. +function typewriter(bubble, fullText) { + const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const { clean, actions } = parseActions(fullText); + if (reduce || clean.length > 1400) { + bubble.innerHTML = renderMarkdown(clean); + appendActionChips(bubble, actions); + scrollToBottom(); + return Promise.resolve(); + } + return new Promise((resolve) => { + const words = clean.split(/(\s+)/); + let i = 0; + const tick = () => { + const chunk = words.slice(i, i + 3).join(''); + bubble.textContent += chunk; + i += 3; + scrollToBottom(); + if (i < words.length) { + setTimeout(tick, 16); + } else { + bubble.innerHTML = renderMarkdown(clean); + appendActionChips(bubble, actions); + scrollToBottom(); + resolve(); + } + }; + tick(); + }); +} + +// Lightweight, no-cost related prompts based on the user's last message. +function renderFollowups(lastUserText) { + const q = (lastUserText || '').toLowerCase(); + const scored = NAV_INDEX + .map((e) => { + let s = 0; + e.keywords.forEach((k) => { if (q.includes(k)) s += 2; }); + if (q.includes(e.name.toLowerCase())) s += 3; + return { e, s }; + }) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, 3); + const wrap = bot.el['ocsb-followups']; + wrap.innerHTML = ''; + if (!scored.length) { wrap.hidden = true; return; } + scored.forEach(({ e }) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'ocsb-followup'; + chip.dataset.text = `Take me to ${e.name}`; + chip.textContent = `→ ${e.name}`; + wrap.appendChild(chip); + }); + wrap.hidden = false; +} + +// ─── Optional backend sync (best-effort, never blocks the UI) ───────────────── +async function remoteSyncMessage(localId, role, content) { + if (!bot.user) return; + try { + const convo = store.getConversation(localId); + if (!convo) return; + let remoteId = convo.remoteId; + if (!remoteId) { + const created = await api.remoteCreate(convo.title || 'New chat'); + if (created && (created.id != null)) { remoteId = created.id; store.setRemoteId(localId, remoteId); } + else return; // backend not available — localStorage already has it + } + await api.remoteAddMessage(remoteId, role, content); + } catch (_e) { /* silent: local copy is the source of truth */ } +} + +async function syncFromRemote() { + // Pull the list of server conversations; if present, surface ones we don't + // have locally yet. Local + remote merge stays simple: never destructive. + try { + const list = await api.remoteList(); + if (!Array.isArray(list) || !list.length) return; + const localRemoteIds = new Set(store.listConversations().map((c) => c.remoteId).filter(Boolean).map(String)); + for (const r of list) { + if (localRemoteIds.has(String(r.id))) continue; + const full = await api.remoteGet(r.id); + if (!full || !Array.isArray(full.messages)) continue; + const convo = store.createConversation(); + store.renameConversation(convo.id, r.title || 'Saved chat'); + store.setRemoteId(convo.id, r.id); + full.messages.forEach((m) => store.appendMessage(convo.id, { role: m.role, content: m.content })); + } + renderRail(); + } catch (_e) { /* silent */ } +} + +// ─── Helpers ────────────────────────────────────────────────────────────── +function scrollToBottom() { + const m = bot.el['ocsb-messages']; + m.scrollTop = m.scrollHeight; +} +function showError(msg) { + bot.el['ocsb-error'].textContent = msg; + bot.el['ocsb-error'].hidden = false; +} +function hideError() { bot.el['ocsb-error'].hidden = true; } +function escapeText(s) { + const d = document.createElement('div'); + d.textContent = String(s == null ? '' : s); + return d.innerHTML; +} +function copyText(text, btn) { + const done = () => { const old = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => (btn.textContent = old), 1400); }; + if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(done); + else { try { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); done(); } catch (_e) {} } +} + +// ─── Go ─────────────────────────────────────────────────────────────────── +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); +else boot(); diff --git a/assets/js/ocs-bot/knowledge.js b/assets/js/ocs-bot/knowledge.js new file mode 100644 index 0000000000..2d84bf26fb --- /dev/null +++ b/assets/js/ocs-bot/knowledge.js @@ -0,0 +1,98 @@ +// assets/js/ocs-bot/knowledge.js +// ----------------------------------------------------------------------------- +// The OCS Assistant's ground truth: who the site is, where things live, and +// what the curriculum covers. Every path here is a REAL permalink verified +// against the pages.opencodingsociety.com repo — the bot is told to only link +// to paths in this index, so it can never invent a broken URL. +// ----------------------------------------------------------------------------- + +export const SITE = { + name: 'Open Coding Society', + short: 'OCS', + url: 'https://pages.opencodingsociety.com', + tagline: + 'A student-driven computer science program — AP CSP, AP CSA, and CS & Software Engineering — built around real projects, "hacks", and a full-stack (GitHub Pages + JavaScript + Flask + Spring) teaching platform.', +}; + +// Each entry: { name, path, keywords[], desc, group } +// group is used to organize the bot's understanding and the welcome screen. +export const NAV_INDEX = [ + // ---- Getting around ---- + { name: 'Home', path: '/home', group: 'Main', keywords: ['home', 'homepage', 'start', 'landing'], desc: 'The OCS home page and gamified entry point.' }, + { name: 'Courses', path: '/navigation/courses/', group: 'Main', keywords: ['courses', 'classes', 'tracks', 'curriculum'], desc: 'Directory of all OCS courses.' }, + { name: 'Lessons / Blogs', path: '/navigation/blogs/', group: 'Main', keywords: ['blogs', 'lessons', 'posts', 'materials', 'index', 'all lessons'], desc: 'Central hub linking every lesson and blog across the courses.' }, + { name: 'About OCS', path: '/navigation/about', group: 'Main', keywords: ['about', 'mission', 'who', 'info', 'open coding society'], desc: 'About the Open Coding Society.' }, + { name: 'Search', path: '/search/', group: 'Main', keywords: ['search', 'find', 'lookup'], desc: 'Search every page and lesson on the site.' }, + + // ---- Accounts ---- + { name: 'Log in', path: '/login', group: 'Account', keywords: ['login', 'log in', 'sign in', 'signin'], desc: 'Sign in to your OCS account (needed to save chats, track progress, and submit work).' }, + { name: 'Sign up', path: '/signup', group: 'Account', keywords: ['signup', 'sign up', 'register', 'create account', 'join'], desc: 'Create a new OCS account.' }, + { name: 'Profile', path: '/profile', group: 'Account', keywords: ['profile', 'my account', 'settings', 'avatar'], desc: 'Your profile, stats, and account settings.' }, + { name: 'Logout', path: '/logout', group: 'Account', keywords: ['logout', 'log out', 'sign out'], desc: 'Sign out of your account.' }, + + // ---- Courses ---- + { name: 'AP Computer Science Principles (CSP)', path: '/navigation/courses/csp', group: 'Courses', keywords: ['csp', 'ap csp', 'computer science principles', 'principles', 'web dev', 'flask', 'cpt', 'create task'], desc: 'AP CSP: 9 sprints covering JavaScript & Python foundations, full-stack web apps (frontend → Flask backend → database), the AP Create Performance Task, and exam prep.' }, + { name: 'AP Computer Science A (CSA)', path: '/navigation/courses/csa', group: 'Courses', keywords: ['csa', 'ap csa', 'computer science a', 'java', 'data structures', 'frq', 'objects', 'inheritance'], desc: 'AP CSA: Java programming, OOP, data structures, algorithms, and Free-Response Question (FRQ) practice across College Board units.' }, + { name: 'CS & Software Engineering (CSSE)', path: '/navigation/courses/csse', group: 'Courses', keywords: ['csse', 'software engineering', 'game dev', 'game development', 'javascript games', 'rpg', 'platformer'], desc: 'CSSE: 6 sprints focused on JavaScript, OOP, and building web games (breakout, platformer, RPG) for Night at the Museum.' }, + { name: 'CSA Lessons', path: '/navigation/csa-lessons/', group: 'Courses', keywords: ['csa lessons', 'java lessons', 'csa units'], desc: 'The full CSA lesson list.' }, + { name: 'CSA Multiple Choice Practice', path: '/csa/mcq', group: 'Courses', keywords: ['mcq', 'multiple choice', 'csa practice', 'ap practice', 'quiz'], desc: 'AP CSA multiple-choice practice questions.' }, + + // ---- CSP curriculum highlights ---- + { name: 'CSP Big Idea 3 — Algorithms & Programming', path: '/csp/big-idea-3', group: 'CSP Topics', keywords: ['big idea 3', 'algorithms', 'programming', 'variables', 'loops', 'conditionals', 'functions', 'lists'], desc: 'The core programming Big Idea: variables, data types, control flow, lists, and procedures.' }, + { name: 'CSP Programming Fundamentals', path: '/csp/big-idea/fundamentals', group: 'CSP Topics', keywords: ['fundamentals', 'basics', 'getting started programming', 'data types'], desc: 'Foundations of programming for CSP.' }, + { name: 'CSP Pseudocode Lesson', path: '/csp/pseudocode/lesson/', group: 'CSP Topics', keywords: ['pseudocode', 'ap pseudocode', 'exam reference'], desc: 'AP CSP pseudocode / exam reference sheet practice.' }, + { name: 'CSP Errors & Debugging', path: '/csp/errors/p3/lesson/', group: 'CSP Topics', keywords: ['errors', 'debugging', 'bugs', 'fix'], desc: 'Finding and fixing errors in programs.' }, + { name: 'CSP Safe Computing', path: '/csp/big-idea-5/safe-computing/', group: 'CSP Topics', keywords: ['safe computing', 'security', 'privacy', 'big idea 5', 'ethics', 'impact'], desc: 'Cybersecurity, privacy, and the impact of computing (Big Idea 5).' }, + { name: 'CSP Compression Quest', path: '/csp/big-idea-2/compression-quest', group: 'CSP Topics', keywords: ['compression', 'data', 'big idea 2', 'lossy', 'lossless'], desc: 'Data & compression (Big Idea 2).' }, + + // ---- Agile / process ---- + { name: 'Agile Development', path: '/agile/', group: 'Process', keywords: ['agile', 'scrum', 'standup', 'kanban', 'teamwork', 'sprint'], desc: 'How OCS teams work: Agile, Scrum, standups, and Kanban.' }, + { name: 'Agile — Teams', path: '/agile/teams', group: 'Process', keywords: ['teams', 'roles', 'collaboration', 'group'], desc: 'Team roles and collaboration practices.' }, + + // ---- Projects, hacks & games ---- + { name: 'CS Portfolio Quest', path: '/cs-portfolio-quest', group: 'Projects', keywords: ['portfolio', 'quest', 'big six', 'showcase', 'ai', 'analytics', 'backend', 'frontend', 'data viz', 'resume'], desc: 'A guided multi-track quest (AI, analytics, backend, frontend, data-viz, resume) for building your CS portfolio.' }, + { name: 'The Big Six', path: '/bigsix', group: 'Projects', keywords: ['big six', 'bigsix', 'the big 6', 'quest game'], desc: 'The Big Six project quest and game.' }, + { name: 'Breakout Game Lesson', path: '/breakout', group: 'Projects', keywords: ['breakout', 'game', 'oop game', 'collision', 'canvas'], desc: 'Build the Breakout game while learning OOP and game loops.' }, + { name: 'Connect Four', path: '/connect4/play/', group: 'Projects', keywords: ['connect 4', 'connect four', 'connect4', 'board game'], desc: 'Play Connect Four; lessons cover the game logic and algorithms.' }, + { name: 'Calculator', path: '/calculator', group: 'Projects', keywords: ['calculator', 'math', 'dom', 'javascript tool'], desc: 'An interactive JavaScript calculator project.' }, + { name: 'Binary Math', path: '/binary-math', group: 'Projects', keywords: ['binary', 'number systems', 'base 2', 'bits', 'conversion'], desc: 'Interactive binary number visualizer and converter.' }, + { name: 'Cookie Clicker Game', path: '/cookie-clicker-game/', group: 'Projects', keywords: ['cookie clicker', 'idle game', 'localstorage'], desc: 'The Cookie Clicker game project with OOP and localStorage lessons.' }, + { name: 'Custom Pong', path: '/custompong', group: 'Projects', keywords: ['pong', 'arcade', 'game'], desc: 'Build your own Pong game.' }, + { name: 'Digital Famine (Cybersecurity Game)', path: '/digital-famine/', group: 'Projects', keywords: ['digital famine', 'cybersecurity', 'media literacy', 'vault', 'security game'], desc: 'A narrative cybersecurity & media-literacy game with missions.' }, + { name: 'CodeGraph', path: '/codegraph', group: 'Projects', keywords: ['codegraph', 'graph', 'data structures', 'visualization'], desc: 'A graph data-structure visualization tool.' }, + { name: 'Crypto Portfolio', path: '/crypto/portfolio', group: 'Projects', keywords: ['crypto', 'portfolio', 'mining', 'blockchain'], desc: 'Crypto portfolio and mining simulation project.' }, + + // ---- Capstone ---- + { name: 'Capstone Projects', path: '/capstone/', group: 'Capstone', keywords: ['capstone', 'final project', 'showcase', 'senior project'], desc: 'Hub of student capstone projects (year-end real-world builds).' }, + { name: 'Capstone — RCR Railroad', path: '/capstone/rcr/', group: 'Capstone', keywords: ['rcr', 'railroad', 'poway midland'], desc: 'Poway–Midland Railroad digital experience capstone.' }, + { name: 'Capstone — PowayNEC', path: '/capstone/powaynec-showcase/', group: 'Capstone', keywords: ['powaynec', 'pnec', 'emergency'], desc: 'Poway Neighborhood Emergency Corps capstone showcase.' }, + { name: 'Capstone — College Bound', path: '/capstone/college-bound', group: 'Capstone', keywords: ['college bound', 'college', 'planning'], desc: 'College-planning platform capstone.' }, + + // ---- Tools, progress & games ---- + { name: 'Leaderboard', path: '/leaderboard', group: 'Tools', keywords: ['leaderboard', 'rankings', 'points', 'standings'], desc: 'Student leaderboard and rankings.' }, + { name: 'Dashboard', path: '/dashboard', group: 'Tools', keywords: ['dashboard', 'overview', 'progress'], desc: 'Your activity dashboard.' }, + { name: 'Snapshot (Portfolio)', path: '/snapshot', group: 'Tools', keywords: ['snapshot', 'portfolio', 'skills', 'assessment'], desc: 'A snapshot of your skills and portfolio.' }, + { name: 'Calendar', path: '/student/calendar', group: 'Tools', keywords: ['calendar', 'schedule', 'due dates', 'events'], desc: 'Course calendar and schedule.' }, + { name: 'Study Tracker', path: '/studytracker', group: 'Tools', keywords: ['study tracker', 'study', 'track', 'habits'], desc: 'Track what you have studied.' }, + { name: 'Grade Predictor', path: '/grade-predictor', group: 'Tools', keywords: ['grade predictor', 'grade', 'predict'], desc: 'Predict your grade.' }, + { name: 'AP Score / Exam Predictor', path: '/exam-predictor', group: 'Tools', keywords: ['exam predictor', 'ap score', 'score predictor', 'predict exam'], desc: 'Estimate your AP exam readiness/score.' }, + { name: 'Linux CTF', path: '/linuxctf', group: 'Tools', keywords: ['linux', 'ctf', 'command line', 'terminal', 'capture the flag'], desc: 'Learn the Linux command line through Capture-the-Flag challenges.' }, + { name: 'Java UI', path: '/javaUI', group: 'Tools', keywords: ['java ui', 'java gui', 'swing'], desc: 'Java user-interface lessons and demos.' }, + { name: 'API Documentation (Java/Spring)', path: '/java/apidocumentation', group: 'Tools', keywords: ['api docs', 'api documentation', 'endpoints', 'spring api'], desc: 'Documentation for the Java/Spring backend APIs.' }, + { name: 'RPG Game', path: '/rpg/latest', group: 'Tools', keywords: ['rpg', 'role playing', 'adventure game'], desc: 'The OCS RPG learning game.' }, + { name: 'Mansion Game', path: '/gamify/mansionGame', group: 'Tools', keywords: ['mansion', 'gamify', 'adventure'], desc: 'The gamified Mansion learning game.' }, +]; + +// High-level course facts the model can answer from directly (no link needed). +export const COURSE_FACTS = ` +COURSES OFFERED (3 main tracks + a college track): +• AP Computer Science Principles (CSP) — 9 sprints. Path: JavaScript & Python foundations → build a web app for "Night at the Museum" (N@tM) → full-stack Create Performance Task (Flask backend, JWT auth, REST APIs, a database) → deployment (AWS EC2) → data structures/data science → AP exam prep → passion project. Great for first-time and broad CS learners. +• AP Computer Science A (CSA) — Java focused. College Board units: Primitive Types (U1), Using Objects (U2), Booleans & if (U3), Iteration (U4), Writing Classes (U5), Arrays/ArrayList, 2D Arrays, Inheritance (U9), Recursion (U10). Heavy on OOP, data structures, algorithms, and FRQ practice. Best if you want the Java AP exam. +• CS & Software Engineering (CSSE) — 6 sprints, game-development focused: JavaScript foundations + student teaching, build web games (Breakout, platformer, RPG) using OOP and a shared game engine, leaderboards/WebSockets, ending in a portfolio + N@tM showcase. +• CWGU — a Western Governors University (D299) college track for dual-credit students. + +THE STACK STUDENTS LEARN (CSP full-stack): +Frontend (GitHub Pages: HTML/CSS/JavaScript) → JavaScript logic/events → Flask (Python) backend on AWS EC2 → data services. Auth uses JWT cookies. The OCS platform itself is the textbook: this very site is a Jekyll site, the Python backend is "flask.opencodingsociety.com", and there is also a Java/Spring backend ("spring.opencodingsociety.com"). + +KEY RITUALS: Agile/Scrum with standups & Kanban; "hacks" (hands-on coding assignments); "popcorn hacks" (quick in-lesson challenges); and "Night at the Museum" (N@tM), the public showcase where students demo their projects. +`.trim(); diff --git a/assets/js/ocs-bot/prompt.js b/assets/js/ocs-bot/prompt.js new file mode 100644 index 0000000000..49dc77870a --- /dev/null +++ b/assets/js/ocs-bot/prompt.js @@ -0,0 +1,64 @@ +// assets/js/ocs-bot/prompt.js +// ----------------------------------------------------------------------------- +// Assembles the system prompt. It teaches the model (a) who OCS is, (b) the +// real site map so it can answer "where is X" accurately, and (c) a strict +// navigation protocol: emit `[[GO:/path|Label]]` tokens that the client turns +// into clickable "open page" chips. The model is told to ONLY use paths from +// the directory below, so it can never produce a broken link. +// ----------------------------------------------------------------------------- + +import { SITE, NAV_INDEX, COURSE_FACTS } from './knowledge.js'; + +function directory() { + const byGroup = {}; + for (const e of NAV_INDEX) (byGroup[e.group] ||= []).push(e); + let out = ''; + for (const group of Object.keys(byGroup)) { + out += `\n[${group}]\n`; + for (const e of byGroup[group]) out += `- ${e.name} → ${e.path} — ${e.desc}\n`; + } + return out.trim(); +} + +const STYLE = { + concise: 'Answer in 1-3 sentences. Lead with the direct answer. No preamble.', + balanced: 'Answer clearly and helpfully, usually 2-5 sentences or a short list. No fluff.', + detailed: 'Give a thorough answer with steps, context, and examples when useful. Use headings/lists for structure.', +}; + +export function buildSystemPrompt({ user, prefs } = {}) { + const style = STYLE[prefs?.style] || STYLE.balanced; + + const userBlock = user + ? `\nSIGNED-IN USER: ${user.name}${user.role ? ` (role: ${user.role})` : ''}. You may greet them by first name ("${user.firstName}") when natural. ${user.isTeacher || user.isAdmin ? 'This user is a teacher/admin — you can mention teacher tools and the dashboard.' : 'This is a student.'}` + : `\nThe user is NOT signed in. If they want to save chats across sessions/devices, track progress, or submit work, suggest they sign in (navigate them to /login) — but only when relevant, never naggingly.`; + + return `You are the **OCS Assistant**, the friendly built-in helper on ${SITE.name}'s website (${SITE.url}). You appear on every page of the site. + +WHO OCS IS: +${SITE.tagline} + +${COURSE_FACTS} + +YOUR JOB: +1. Answer students' and visitors' questions about the courses, lessons, projects, tools, and how the site works — accurately and specifically. +2. Help people NAVIGATE. When the user asks to go somewhere ("take me to…", "open…", "where is…", "how do I get to…"), or when pointing to a page is genuinely the best next step, guide them there. +3. Be encouraging and concise. You're talking to learners — be clear, never condescending. + +SITE DIRECTORY (the ONLY paths you may link to — never invent a URL; if something isn't here, send them to /search/ or /navigation/blogs/): +${directory()} + +NAVIGATION PROTOCOL — VERY IMPORTANT: +- To offer a clickable link to a page, output a token EXACTLY like: [[GO:/path|Short Label]] + Example: "Sure — here's the AP CSP course. [[GO:/navigation/courses/csp|Open AP CSP]]" +- Put the [[GO:...]] token at the END of the relevant sentence or your message. You may include up to 3 tokens. +- ONLY use paths that appear in the SITE DIRECTORY above, copied exactly. +- Use a token whenever a page would help — don't just describe a page, link it. +- Do NOT wrap the token in backticks or code. Do NOT use [[GO:...]] for external sites. + +STYLE: ${style} +- Format with Markdown (short paragraphs, **bold**, bullet lists, and \`code\`/code blocks for code). +- If you're unsure or the answer isn't about OCS, say so briefly and point to /search/ or /navigation/blogs/. +- Never fabricate lesson names, deadlines, or grades. Stick to what's in this prompt; for specifics you don't know, suggest where on the site to look. +${userBlock}`; +} diff --git a/assets/js/ocs-bot/render.js b/assets/js/ocs-bot/render.js new file mode 100644 index 0000000000000000000000000000000000000000..223dbcae9e7c616fd9403811ad8f9d76b9811813 GIT binary patch literal 3930 zcmb_fO>^5e5Y5@YVk2uJ(w4+=noDCzo;GzRooU)mlb%Gmf+)yD41z2H#q~J)?|lo9 z5^JX)(>8}#BDlM6ci+B+l7xh{(%IyLP0GxUR;5d7Y1XnHf3QbMLZkolA3J=jj4Fg4 z(z~x-Us56Jn{|0>Xf&fY@85n$o3ds|$~s{j71CO9E$x^tKg#;f<;q->vi{W(dy_U3 zm0Z*7Z-09Cen_^YqFhT&<EJ4)ko=rZmYy*wW{2h-sjSgU~d+{ zG}-M6C82V2K!=dE9EqyZO0HkhMw$#kPvKQ7BPm17A+1Wi9+GlGt8B>baCT!UQpU;J z2;F=yD}=M6kRvBnLz2HWr~bJD5+J@MoW(=RrC1|n+qkpN4oR3bt#p~){JJe2O2d)< z&nAtIjy7A9IaQjbyl-5gBOBBG5uprjTN`@kYT&{{JR%y`veF`xQSvOg9@5~MD5~?p z!?Q^^)9&%nEF4{b=BO8rIN8T`gK&8K#g{lcKiYMmht@<6^$o~1dg^FO{Nx<4QMOL| zD5`3d6BOB~+B&*bE=Ma|c_YmQSyfb85(rS2BFkS=jxONAN`kMFN|(#p*TM%llw%pq zua=9`c*!`UUy31RF->Q*oG@i({rhY;N&Lqp;CoEWB7#5DFbPi|I89H|6Zk!WU;gMz zFtM&K&GoE*Hc6U~T#!e$(bXrmgduxKjrZX_z)}{o{_{zKKfx9i+Rta;@|=seh_nBsu}~_ z;$of?sSQQRyf>IFk}>AYan@st*_P4hERN%L6pC}Ek`){2?z~|$BFp|gyzcth z0J|l(W#@Bk-UJ=H%zaoEKX+Maz)EE=Te+B0a@EN(QG1XvJ$pt^6V?+@i{+!t6u_n9 ztSq|kF_7R_I9FTDM#ujJ4Io1GwRF?oFDos~O%ECCY0s4G8Io#1{f(?^S@#C?k_H3z z*Csy^l;e2X>)#{w9sc+jJ8<529K07Zlmdy%->S04+_j<;_Rrg9GeAC}xUqaEx=arc7Q(C(p5|L}wisJ1bQcX$(rPD9_7G81NQiF zA8hr2K)O;7;F-x#iCQ#f`&b)|ivJi6qE5G2TKN5@!yq?PGV}+H!HhK9%fIxYR3;h_ zI*OWBGi-`ZFxny7?l8%jF0K3yh=HXE>6NTa;z6v0vT-50`Td6Zm5$MnWhk1F*`a7M^ z)UThbS!yqu^}|{HO}RQS4mZRD5?`17RW0}qQUQ1!gFi#~GZY`c3~=*hmUxtp%AMMi z`RHWv&#KKi?g{sFIwc+}?#UlWu+M5R*=oL;`0&j{vIW@D?SNK?l(}16d_4QJtbX_> zIi>5<`8fX9X+b9bDy{NyTCn(UX}yB(k9I*72+rRTdVTQeb}S(@3+ND){%-A Oor_1a)Yu*0YJLaim#)SD literal 0 HcmV?d00001 diff --git a/assets/js/ocs-bot/store.js b/assets/js/ocs-bot/store.js new file mode 100644 index 0000000000..ec3f32c615 --- /dev/null +++ b/assets/js/ocs-bot/store.js @@ -0,0 +1,178 @@ +// assets/js/ocs-bot/store.js +// ----------------------------------------------------------------------------- +// Local persistence. Conversations are namespaced PER USER so that signing in +// gives you your own saved history (and a shared device never mixes accounts): +// key = `ocs_bot_v1:` (userId = 'guest' when signed out) +// Preferences are global. A server-sync layer (api.remote*) can attach a +// `remoteId` to a conversation; this store stays the source of truth for the +// UI and degrades gracefully when the backend isn't available. +// ----------------------------------------------------------------------------- + +const PREFIX = 'ocs_bot_v1:'; +const PREFS_KEY = 'ocs_bot_prefs_v1'; +const SCHEMA_VERSION = 1; +const MAX_CONVERSATIONS = 40; +const MAX_MESSAGES = 200; + +let scopeKey = PREFIX + 'guest'; + +export function uid() { + try { + if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); + } catch (_e) { /* fall through */ } + return 'id-' + Math.random().toString(36).slice(2) + '-' + new Date().getTime().toString(36); +} + +function emptyState() { + return { version: SCHEMA_VERSION, activeId: null, conversations: [] }; +} + +function safeParse(raw, fallback) { + if (!raw) return fallback; + try { + const v = JSON.parse(raw); + return v && typeof v === 'object' ? v : fallback; + } catch (_e) { + return fallback; + } +} + +function load() { + let state; + try { state = safeParse(localStorage.getItem(scopeKey), emptyState()); } + catch (_e) { state = emptyState(); } + if (!Array.isArray(state.conversations)) state.conversations = []; + return state; +} + +function save(state) { + try { + localStorage.setItem(scopeKey, JSON.stringify(state)); + } catch (_e) { + // Quota hit — drop the oldest conversations and retry once. + state.conversations = state.conversations + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, 10); + try { localStorage.setItem(scopeKey, JSON.stringify(state)); } catch (_e2) { /* give up silently */ } + } +} + +// ─── Scope (which user owns this storage) ──────────────────────────────────── +export function setScope(userId) { + scopeKey = PREFIX + (userId || 'guest'); +} + +// ─── Conversations ─────────────────────────────────────────────────────────── +export function listConversations() { + return load().conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt); +} + +export function getActiveId() { + return load().activeId; +} + +export function setActiveId(id) { + const s = load(); + s.activeId = id; + save(s); +} + +export function getConversation(id) { + return load().conversations.find((c) => c.id === id) || null; +} + +export function createConversation() { + const s = load(); + const now = new Date().getTime(); + const convo = { id: uid(), title: 'New chat', createdAt: now, updatedAt: now, remoteId: null, messages: [] }; + s.conversations.unshift(convo); + // Trim to cap (keep most recent). + if (s.conversations.length > MAX_CONVERSATIONS) { + s.conversations = s.conversations.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_CONVERSATIONS); + } + s.activeId = convo.id; + save(s); + return convo; +} + +export function appendMessage(conversationId, message) { + const s = load(); + const convo = s.conversations.find((c) => c.id === conversationId); + if (!convo) return null; + const msg = { + id: message.id || uid(), + role: message.role, + content: message.content, + ts: message.ts || new Date().getTime(), + }; + convo.messages.push(msg); + if (convo.messages.length > MAX_MESSAGES) convo.messages = convo.messages.slice(-MAX_MESSAGES); + // Auto-title from the first user message. + if ((convo.title === 'New chat' || !convo.title) && message.role === 'user') { + convo.title = message.content.trim().replace(/\s+/g, ' ').slice(0, 48) || 'New chat'; + } + convo.updatedAt = msg.ts; + save(s); + return msg; +} + +export function updateLastAssistant(conversationId, content) { + const s = load(); + const convo = s.conversations.find((c) => c.id === conversationId); + if (!convo) return; + for (let i = convo.messages.length - 1; i >= 0; i--) { + if (convo.messages[i].role === 'assistant') { convo.messages[i].content = content; break; } + } + convo.updatedAt = new Date().getTime(); + save(s); +} + +export function setRemoteId(conversationId, remoteId) { + const s = load(); + const convo = s.conversations.find((c) => c.id === conversationId); + if (convo) { convo.remoteId = remoteId; save(s); } +} + +export function renameConversation(id, title) { + const s = load(); + const convo = s.conversations.find((c) => c.id === id); + if (convo) { convo.title = (title || 'Untitled').slice(0, 60); convo.updatedAt = new Date().getTime(); save(s); } +} + +export function deleteConversation(id) { + const s = load(); + s.conversations = s.conversations.filter((c) => c.id !== id); + if (s.activeId === id) s.activeId = s.conversations.length ? s.conversations[0].id : null; + save(s); +} + +export function clearMessages(id) { + const s = load(); + const convo = s.conversations.find((c) => c.id === id); + if (convo) { convo.messages = []; convo.title = 'New chat'; convo.updatedAt = new Date().getTime(); save(s); } +} + +export function searchConversations(query) { + const q = (query || '').trim().toLowerCase(); + if (!q) return listConversations(); + return listConversations().filter((c) => + (c.title || '').toLowerCase().includes(q) || + c.messages.some((m) => (m.content || '').toLowerCase().includes(q)) + ); +} + +// ─── Preferences (global, cross-user) ──────────────────────────────────────── +const DEFAULT_PREFS = { style: 'balanced', model: 'llama-3.3-70b-versatile', railOpen: false }; + +export function getPrefs() { + let raw; + try { raw = localStorage.getItem(PREFS_KEY); } catch (_e) { raw = null; } + return Object.assign({}, DEFAULT_PREFS, safeParse(raw, {})); +} + +export function setPref(key, value) { + const prefs = getPrefs(); + prefs[key] = value; + try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch (_e) { /* ignore */ } + return prefs; +} diff --git a/assets/js/ocs-bot/suggestions.js b/assets/js/ocs-bot/suggestions.js new file mode 100644 index 0000000000..96f0641e36 --- /dev/null +++ b/assets/js/ocs-bot/suggestions.js @@ -0,0 +1,31 @@ +// assets/js/ocs-bot/suggestions.js +// ----------------------------------------------------------------------------- +// Curated starter prompts for the welcome screen. Each becomes a clickable +// chip that pre-fills + sends. Tuned to what real OCS visitors ask. +// ----------------------------------------------------------------------------- + +const POOL = [ + { icon: '🧭', text: "Which course should I start with?" }, + { icon: '📚', text: "What does the AP CSP course cover?" }, + { icon: '☕', text: "Take me to the AP CSA Java lessons" }, + { icon: '🎮', text: "How do I build a game in CSSE?" }, + { icon: '🔌', text: "Explain the full-stack: frontend, Flask, and database" }, + { icon: '🧩', text: "Where are the coding 'hacks' and projects?" }, + { icon: '🏆', text: "Show me the leaderboard" }, + { icon: '🔍', text: "How do I search the site?" }, + { icon: '🗓️', text: "Where's the course calendar?" }, + { icon: '🚀', text: "What is 'Night at the Museum'?" }, + { icon: '🧠', text: "Help me prep for the AP CSA exam" }, + { icon: '🔐', text: "Open the Linux CTF challenges" }, +]; + +export function starterSuggestions(user) { + const picks = []; + if (!user) picks.push({ icon: '👤', text: 'How do I sign up for an OCS account?' }); + // Stable-ish rotation so it doesn't feel random within a session. + const start = (new Date().getHours() * 3) % POOL.length; + for (let i = 0; picks.length < 5 && i < POOL.length; i++) { + picks.push(POOL[(start + i) % POOL.length]); + } + return picks.slice(0, 5); +} diff --git a/assets/js/ocs-bot/tools.js b/assets/js/ocs-bot/tools.js new file mode 100644 index 0000000000..ced7684b9c --- /dev/null +++ b/assets/js/ocs-bot/tools.js @@ -0,0 +1,56 @@ +// assets/js/ocs-bot/tools.js +// ----------------------------------------------------------------------------- +// Parses the navigation protocol out of assistant text. The model emits +// `[[GO:/path|Label]]` tokens; we strip them from the visible message and turn +// them into validated, clickable navigation chips. Only safe, internal paths +// are accepted (must start with "/" — never javascript:, data:, or external). +// ----------------------------------------------------------------------------- + +import { NAV_INDEX } from './knowledge.js'; + +const KNOWN = new Set(NAV_INDEX.map((e) => e.path.replace(/\/$/, ''))); +const TOKEN = /\[\[GO:\s*([^|\]]+?)\s*(?:\|\s*([^\]]+?))?\s*\]\]/g; + +function isSafeInternal(path) { + if (typeof path !== 'string') return false; + const p = path.trim(); + // internal absolute path only + if (!p.startsWith('/')) return false; + if (p.startsWith('//')) return false; // protocol-relative + if (/[\s<>"'`]/.test(p)) return false; + if (/^\/+javascript:/i.test(p)) return false; + return true; +} + +// Returns { clean, actions: [{path, label}] } +export function parseActions(text) { + const actions = []; + const seen = new Set(); + const clean = String(text || '') + .replace(TOKEN, (_m, rawPath, rawLabel) => { + const path = (rawPath || '').trim(); + if (!isSafeInternal(path)) return ''; + const norm = path.replace(/\/$/, ''); + if (seen.has(norm)) return ''; + seen.add(norm); + // Prefer the model's label; fall back to the directory name. + const known = NAV_INDEX.find((e) => e.path.replace(/\/$/, '') === norm); + const label = (rawLabel || '').trim() || (known ? known.name : 'Open page'); + actions.push({ path, label, known: KNOWN.has(norm) }); + return ''; + }) + // tidy up any double spaces / dangling spaces left where tokens were + .replace(/[ \t]{2,}/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); + return { clean, actions: actions.slice(0, 3) }; +} + +// Resolve a site-root-relative path to a full href, honoring Jekyll baseurl +// if the host page exposes one (window.OCS_BASEURL). baseurl is "" in prod. +export function hrefFor(path) { + const base = (typeof window !== 'undefined' && window.OCS_BASEURL) || ''; + if (/^https?:\/\//i.test(path)) return path; + return base.replace(/\/$/, '') + path; +} From aebe06715cf54b74e9649e55ff8a455bfb4571a9 Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 13:44:13 -0700 Subject: [PATCH 02/11] Refine OCS Assistant UI: code-dark glass design language - Rebuilt the visual system to a layered 'code-dark glass' look (backdrop-blur panel, subtle brand glow, depth via shadows/inner highlights) instead of flat. - Replaced all emoji icons with crisp monoline SVGs (bot/user avatars and the starter-suggestion tiles) for a more premium, consistent feel. - Distinct message hierarchy: assistant on a soft glass surface, user on the brand gradient; navigation chips are now green 'go' affordances. - Inter typography, refined composer/settings/rail, hover chevrons on suggestions, thinner scrollbars, and tightened spacing/contrast. - Preserved all behavior, IDs, accessibility, responsive + reduced-motion. --- assets/css/ocs-bot.css | 507 +++++++++++++++---------------- assets/js/ocs-bot/index.js | 33 +- assets/js/ocs-bot/suggestions.js | 31 +- 3 files changed, 290 insertions(+), 281 deletions(-) diff --git a/assets/css/ocs-bot.css b/assets/css/ocs-bot.css index 9b722174ee..e9392fd93e 100644 --- a/assets/css/ocs-bot.css +++ b/assets/css/ocs-bot.css @@ -1,385 +1,378 @@ /* ============================================================================= OCS Assistant — global chatbot widget for pages.opencodingsociety.com - Dark, glassy design system tuned to the Open Coding Society brand - (deep slate surfaces, electric-blue → cyan accent). Self-contained: - every selector is namespaced under .ocsb- so it can never collide with - site styles. Loads below-the-fold (a fixed launcher button), so there is - no flash-of-unstyled-content even though the stylesheet is linked late. + "Code-dark glass" design language: layered translucent surfaces with depth + (backdrop-blur), a refined slate palette, an OCS blue→cyan brand accent, and + green "go" affordances for navigation (code-dark + run-green). Everything is + namespaced .ocsb- / #ocsb- so it can never collide with site styles. Loads + below-the-fold (a fixed launcher), so there is no flash of unstyled content. ============================================================================= */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + :root { - /* ---- Surfaces ---- */ - --ocsb-bg-fab: linear-gradient(135deg, #2f8fff 0%, #1167d6 100%); - --ocsb-bg-panel: #0f1620; - --ocsb-bg-panel-2: #131c28; - --ocsb-bg-rail: #0b1119; - --ocsb-bg-head: rgba(15, 22, 32, 0.92); - --ocsb-bg-input: #0c131c; - --ocsb-bg-bot: #1a2533; - --ocsb-bg-user: linear-gradient(135deg, #2f8fff 0%, #1f74e0 100%); - --ocsb-bg-chip: rgba(79, 175, 239, 0.10); - --ocsb-bg-chip-hover: rgba(79, 175, 239, 0.20); - --ocsb-bg-code: #060b12; + /* ---- Surfaces (layered slate, glass) ---- */ + --ocsb-bg: #0a0f17; + --ocsb-panel: rgba(15, 21, 32, 0.82); + --ocsb-panel-solid: #0e1521; + --ocsb-rail: rgba(9, 13, 20, 0.7); + --ocsb-surface: rgba(255, 255, 255, 0.035); + --ocsb-surface-2: rgba(255, 255, 255, 0.06); + --ocsb-surface-3: rgba(255, 255, 255, 0.09); + --ocsb-code: #070b12; /* ---- Text ---- */ - --ocsb-fg: #eef4fb; - --ocsb-fg-muted: #9fb1c4; - --ocsb-fg-faint: #6b7d92; + --ocsb-fg: #eaf1fa; + --ocsb-fg-muted: #9bacbe; + --ocsb-fg-faint: #6a7889; --ocsb-fg-on-accent: #ffffff; - /* ---- Accent ---- */ - --ocsb-accent: #4cafef; - --ocsb-accent-2: #38e2c2; /* cyan-green, OCS game accent */ - --ocsb-accent-deep: #1167d6; - --ocsb-accent-soft: rgba(76, 175, 239, 0.16); - --ocsb-glow: rgba(47, 143, 255, 0.45); + /* ---- Brand (OCS blue → cyan) ---- */ + --ocsb-brand: #4cafef; + --ocsb-brand-2: #38bdf8; + --ocsb-brand-cyan: #22d3ee; + --ocsb-brand-deep: #1f74e0; + --ocsb-brand-grad: linear-gradient(135deg, #56b6f4 0%, #2f7ff0 100%); + --ocsb-brand-soft: rgba(76, 175, 239, 0.14); + --ocsb-brand-glow: rgba(56, 130, 240, 0.5); + + /* ---- "Go" / navigate (run-green) ---- */ + --ocsb-go: #34d399; + --ocsb-go-text: #5ee9b5; + --ocsb-go-soft: rgba(52, 211, 153, 0.12); + --ocsb-go-border: rgba(52, 211, 153, 0.34); /* ---- Lines ---- */ - --ocsb-border: rgba(127, 165, 204, 0.16); - --ocsb-border-strong: rgba(127, 165, 204, 0.28); + --ocsb-border: rgba(168, 195, 224, 0.10); + --ocsb-border-strong: rgba(168, 195, 224, 0.18); + --ocsb-hairline: rgba(255, 255, 255, 0.06); /* ---- Status ---- */ - --ocsb-ok: #34c759; - --ocsb-warn: #f59e0b; - --ocsb-err: #ff5c6c; + --ocsb-ok: #34d399; + --ocsb-err: #ff6b78; /* ---- Shape / motion ---- */ - --ocsb-radius-panel: 20px; - --ocsb-radius-msg: 16px; - --ocsb-radius-chip: 999px; - --ocsb-shadow-fab: 0 10px 30px rgba(17, 103, 214, 0.45), 0 2px 8px rgba(0, 0, 0, 0.35); - --ocsb-shadow-panel: 0 24px 70px rgba(0, 0, 0, 0.55), 0 0 0 1px var(--ocsb-border); - --ocsb-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - --ocsb-mono: "JetBrains Mono", "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace; + --ocsb-r-panel: 22px; + --ocsb-r-card: 16px; + --ocsb-r-msg: 15px; + --ocsb-r-pill: 999px; + --ocsb-ease: cubic-bezier(0.22, 1, 0.36, 1); + --ocsb-shadow-fab: 0 12px 32px rgba(20, 90, 200, 0.40), 0 2px 6px rgba(0, 0, 0, 0.4); + --ocsb-shadow-panel: + 0 32px 80px -12px rgba(0, 0, 0, 0.7), + 0 0 0 1px var(--ocsb-border), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + --ocsb-shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.28); + --ocsb-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + --ocsb-mono: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, Menlo, Consolas, monospace; - /* ---- Layering (stay above OCS nav/footer) ---- */ + /* ---- Layering (above OCS nav/footer) ---- */ --ocsb-z-fab: 99990; --ocsb-z-backdrop: 99994; --ocsb-z-panel: 99995; } -/* Hard reset inside the widget only — protects against inherited site rules. */ +/* Scoped reset — protects the widget from inherited site rules. */ #ocsb-fab, #ocsb-fab *, #ocsb-panel, #ocsb-panel * { box-sizing: border-box; margin: 0; padding: 0; font-family: var(--ocsb-font); + -webkit-font-smoothing: antialiased; } /* ============================ Launcher (FAB) ============================ */ #ocsb-fab { position: fixed; - right: 22px; - bottom: 22px; + right: 24px; + bottom: 24px; z-index: var(--ocsb-z-fab); display: inline-flex; align-items: center; - gap: 10px; - padding: 13px 18px 13px 15px; - border: none; - border-radius: var(--ocsb-radius-chip); - background: var(--ocsb-bg-fab); + gap: 11px; + padding: 12px 18px 12px 13px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: var(--ocsb-r-pill); + background: var(--ocsb-brand-grad); color: var(--ocsb-fg-on-accent); - font-size: 0.95rem; + font-size: 0.92rem; font-weight: 700; letter-spacing: -0.01em; cursor: pointer; - box-shadow: var(--ocsb-shadow-fab); - transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1), - box-shadow 320ms ease, opacity 200ms ease; + box-shadow: var(--ocsb-shadow-fab), inset 0 1px 0 rgba(255, 255, 255, 0.25); + transition: transform 240ms var(--ocsb-ease), box-shadow 280ms ease, opacity 200ms ease; } -#ocsb-fab:hover { transform: translateY(-2px) scale(1.02); } -#ocsb-fab:active { transform: scale(0.97); } -#ocsb-fab:focus-visible { outline: 3px solid var(--ocsb-accent-2); outline-offset: 3px; } +#ocsb-fab:hover { transform: translateY(-2px); box-shadow: 0 16px 40px rgba(20, 90, 200, 0.5), inset 0 1px 0 rgba(255,255,255,0.28); } +#ocsb-fab:active { transform: translateY(0); } +#ocsb-fab:focus-visible { outline: 3px solid var(--ocsb-brand-cyan); outline-offset: 3px; } #ocsb-fab .ocsb-fab-icon { - display: grid; - place-items: center; - width: 30px; - height: 30px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.18); + display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; + background: rgba(255, 255, 255, 0.2); } -#ocsb-fab .ocsb-fab-icon svg { width: 18px; height: 18px; } +#ocsb-fab .ocsb-fab-icon svg { width: 17px; height: 17px; } #ocsb-fab .ocsb-fab-pulse { - position: absolute; - inset: 0; - border-radius: inherit; - box-shadow: 0 0 0 0 var(--ocsb-glow); - animation: ocsb-pulse 2.6s ease-out infinite; - pointer-events: none; + position: absolute; inset: -1px; border-radius: inherit; pointer-events: none; + box-shadow: 0 0 0 0 var(--ocsb-brand-glow); animation: ocsb-pulse 3s ease-out infinite; } @keyframes ocsb-pulse { - 0% { box-shadow: 0 0 0 0 var(--ocsb-glow); } - 70% { box-shadow: 0 0 0 14px rgba(47, 143, 255, 0); } - 100% { box-shadow: 0 0 0 0 rgba(47, 143, 255, 0); } + 0% { box-shadow: 0 0 0 0 var(--ocsb-brand-glow); } + 60% { box-shadow: 0 0 0 12px rgba(56, 130, 240, 0); } + 100% { box-shadow: 0 0 0 0 rgba(56, 130, 240, 0); } } -/* Hide FAB while the panel is open */ -body.ocsb-open #ocsb-fab { transform: scale(0); opacity: 0; pointer-events: none; } +body.ocsb-open #ocsb-fab { transform: scale(0.6); opacity: 0; pointer-events: none; } /* ============================ Backdrop ============================ */ #ocsb-backdrop { - position: fixed; - inset: 0; - z-index: var(--ocsb-z-backdrop); - background: rgba(4, 8, 14, 0.55); - opacity: 0; - visibility: hidden; - transition: opacity 220ms ease, visibility 220ms ease; + position: fixed; inset: 0; z-index: var(--ocsb-z-backdrop); + background: rgba(4, 7, 12, 0.6); backdrop-filter: blur(2px); + opacity: 0; visibility: hidden; transition: opacity 240ms ease, visibility 240ms ease; } body.ocsb-open.ocsb-modal #ocsb-backdrop { opacity: 1; visibility: visible; } /* ============================ Panel ============================ */ #ocsb-panel { - position: fixed; - right: 22px; - bottom: 22px; - z-index: var(--ocsb-z-panel); - width: 408px; - height: min(640px, calc(100vh - 44px)); - display: grid; - grid-template-columns: 0fr 1fr; - background: var(--ocsb-bg-panel); - border-radius: var(--ocsb-radius-panel); + position: fixed; right: 24px; bottom: 24px; z-index: var(--ocsb-z-panel); + width: 416px; height: min(660px, calc(100vh - 48px)); + display: grid; grid-template-columns: 0fr 1fr; + background: var(--ocsb-panel); + -webkit-backdrop-filter: blur(28px) saturate(150%); + backdrop-filter: blur(28px) saturate(150%); + border-radius: var(--ocsb-r-panel); box-shadow: var(--ocsb-shadow-panel); overflow: hidden; - opacity: 0; - visibility: hidden; - transform: translateY(16px) scale(0.98); - transform-origin: bottom right; - transition: opacity 200ms ease, transform 240ms cubic-bezier(0.22, 1, 0.36, 1), - visibility 200ms ease, width 260ms ease, height 260ms ease; + opacity: 0; visibility: hidden; + transform: translateY(18px) scale(0.97); transform-origin: bottom right; + transition: opacity 220ms ease, transform 260ms var(--ocsb-ease), visibility 220ms ease, width 280ms var(--ocsb-ease), height 280ms var(--ocsb-ease); color: var(--ocsb-fg); } +/* soft brand glow bleeding from the top — adds depth */ +#ocsb-panel::before { + content: ''; position: absolute; top: -40%; left: 50%; transform: translateX(-50%); + width: 120%; height: 220px; pointer-events: none; z-index: 0; + background: radial-gradient(60% 60% at 50% 0%, rgba(56, 130, 240, 0.18), transparent 70%); +} body.ocsb-open #ocsb-panel { opacity: 1; visibility: visible; transform: translateY(0) scale(1); } -body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 168px 1fr; } - -/* Expanded mode — bigger card */ -body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(720px, calc(100vw - 44px)); height: min(800px, calc(100vh - 44px)); } -body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: 220px 1fr; } +body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 176px 1fr; } +body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(760px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px)); } +body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: 232px 1fr; } /* ---- Conversation rail ---- */ #ocsb-rail { - background: var(--ocsb-bg-rail); - border-right: 1px solid var(--ocsb-border); - display: flex; - flex-direction: column; - min-width: 0; - overflow: hidden; + position: relative; z-index: 1; + background: var(--ocsb-rail); border-right: 1px solid var(--ocsb-hairline); + display: flex; flex-direction: column; min-width: 0; overflow: hidden; } -.ocsb-rail-head { padding: 12px 10px 8px; } +.ocsb-rail-head { padding: 14px 11px 8px; } #ocsb-new { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - padding: 9px 10px; - border: 1px solid var(--ocsb-border-strong); - border-radius: 11px; - background: var(--ocsb-bg-chip); - color: var(--ocsb-fg); - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - transition: background 160ms ease, border-color 160ms ease; + width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 7px; + padding: 10px; border: 1px solid var(--ocsb-border-strong); border-radius: 12px; + background: var(--ocsb-surface-2); color: var(--ocsb-fg); font-size: 0.82rem; font-weight: 600; + cursor: pointer; transition: background 180ms ease, border-color 180ms ease; } -#ocsb-new:hover { background: var(--ocsb-bg-chip-hover); border-color: var(--ocsb-accent); } +#ocsb-new:hover { background: var(--ocsb-brand-soft); border-color: var(--ocsb-brand); } #ocsb-new svg { width: 15px; height: 15px; } -.ocsb-rail-search { padding: 2px 10px 8px; } +.ocsb-rail-search { padding: 2px 11px 8px; } .ocsb-rail-search input { - width: 100%; - padding: 7px 10px; - border: 1px solid var(--ocsb-border); - border-radius: 9px; - background: var(--ocsb-bg-input); - color: var(--ocsb-fg); - font-size: 0.78rem; + width: 100%; padding: 8px 11px; border: 1px solid var(--ocsb-border); border-radius: 10px; + background: rgba(0, 0, 0, 0.25); color: var(--ocsb-fg); font-size: 0.78rem; } .ocsb-rail-search input::placeholder { color: var(--ocsb-fg-faint); } -.ocsb-rail-search input:focus { outline: none; border-color: var(--ocsb-accent); } -#ocsb-convos { list-style: none; flex: 1; overflow-y: auto; padding: 2px 8px 10px; } -#ocsb-convos li { margin-bottom: 3px; } +.ocsb-rail-search input:focus { outline: none; border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-soft); } +#ocsb-convos { list-style: none; flex: 1; overflow-y: auto; padding: 2px 9px 12px; } +#ocsb-convos li { margin-bottom: 2px; } .ocsb-convo { - display: flex; - align-items: center; - gap: 6px; - width: 100%; - padding: 8px 9px; - border: 1px solid transparent; - border-radius: 9px; - background: transparent; - color: var(--ocsb-fg-muted); - font-size: 0.8rem; - text-align: left; - cursor: pointer; - transition: background 140ms ease, color 140ms ease; + display: flex; align-items: center; gap: 7px; width: 100%; padding: 9px 10px; + border: 1px solid transparent; border-radius: 10px; background: transparent; + color: var(--ocsb-fg-muted); font-size: 0.8rem; text-align: left; cursor: pointer; + transition: background 160ms ease, color 160ms ease; } -.ocsb-convo:hover { background: rgba(255, 255, 255, 0.04); color: var(--ocsb-fg); } -.ocsb-convo.is-active { background: var(--ocsb-accent-soft); color: var(--ocsb-fg); border-color: var(--ocsb-border-strong); } +.ocsb-convo:hover { background: var(--ocsb-surface); color: var(--ocsb-fg); } +.ocsb-convo.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-fg); border-color: var(--ocsb-border-strong); } .ocsb-convo-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ocsb-convo-del { - flex: none; display: none; width: 20px; height: 20px; border: none; border-radius: 6px; - background: transparent; color: var(--ocsb-fg-faint); cursor: pointer; font-size: 0.9rem; line-height: 1; + flex: none; display: none; width: 22px; height: 22px; border: none; border-radius: 7px; + background: transparent; color: var(--ocsb-fg-faint); cursor: pointer; font-size: 1rem; line-height: 1; } .ocsb-convo:hover .ocsb-convo-del { display: grid; place-items: center; } -.ocsb-convo-del:hover { color: var(--ocsb-err); background: rgba(255, 92, 108, 0.12); } -.ocsb-rail-empty { padding: 12px 12px; color: var(--ocsb-fg-faint); font-size: 0.74rem; line-height: 1.5; } +.ocsb-convo-del:hover { color: var(--ocsb-err); background: rgba(255, 107, 120, 0.14); } +.ocsb-rail-empty { padding: 14px 12px; color: var(--ocsb-fg-faint); font-size: 0.74rem; line-height: 1.6; } /* ---- Main column ---- */ -.ocsb-main { display: flex; flex-direction: column; min-width: 0; background: var(--ocsb-bg-panel); } +.ocsb-main { position: relative; z-index: 1; display: flex; flex-direction: column; min-width: 0; } /* Header */ #ocsb-head { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 12px 11px; - background: var(--ocsb-bg-head); - border-bottom: 1px solid var(--ocsb-border); - backdrop-filter: blur(6px); + display: flex; align-items: center; gap: 11px; padding: 14px 13px 13px; + border-bottom: 1px solid var(--ocsb-hairline); } .ocsb-head-avatar { - flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 11px; - background: var(--ocsb-bg-fab); color: #fff; box-shadow: 0 4px 12px rgba(17, 103, 214, 0.4); + flex: none; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; + background: var(--ocsb-brand-grad); color: #fff; + box-shadow: 0 6px 16px rgba(31, 116, 224, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.3); } -.ocsb-head-avatar svg { width: 20px; height: 20px; } +.ocsb-head-avatar svg { width: 21px; height: 21px; } .ocsb-head-meta { flex: 1; min-width: 0; } -.ocsb-head-meta h2 { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--ocsb-fg); } -.ocsb-status { display: flex; align-items: center; gap: 6px; font-size: 0.72rem; color: var(--ocsb-fg-muted); margin-top: 1px; } -.ocsb-status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-ok); box-shadow: 0 0 0 0 rgba(52, 199, 89, 0.5); animation: ocsb-dot 2.4s ease-out infinite; } -@keyframes ocsb-dot { 0% { box-shadow: 0 0 0 0 rgba(52,199,89,0.5);} 70%{ box-shadow:0 0 0 6px rgba(52,199,89,0);} 100%{box-shadow:0 0 0 0 rgba(52,199,89,0);} } +.ocsb-head-meta h2 { font-size: 0.96rem; font-weight: 700; letter-spacing: -0.015em; color: var(--ocsb-fg); } +.ocsb-status { display: flex; align-items: center; gap: 6px; font-size: 0.72rem; color: var(--ocsb-fg-muted); margin-top: 2px; } +.ocsb-status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-ok); box-shadow: 0 0 8px var(--ocsb-ok); } .ocsb-head-actions { display: flex; align-items: center; gap: 2px; } .ocsb-icon-btn { - display: grid; place-items: center; width: 32px; height: 32px; border: none; border-radius: 9px; - background: transparent; color: var(--ocsb-fg-muted); cursor: pointer; transition: background 140ms ease, color 140ms ease; + display: grid; place-items: center; width: 33px; height: 33px; border: none; border-radius: 9px; + background: transparent; color: var(--ocsb-fg-muted); cursor: pointer; + transition: background 160ms ease, color 160ms ease; } -.ocsb-icon-btn:hover { background: rgba(255, 255, 255, 0.06); color: var(--ocsb-fg); } -.ocsb-icon-btn:focus-visible { outline: 2px solid var(--ocsb-accent); outline-offset: 1px; } +.ocsb-icon-btn:hover { background: var(--ocsb-surface-2); color: var(--ocsb-fg); } +.ocsb-icon-btn:focus-visible { outline: 2px solid var(--ocsb-brand); outline-offset: 1px; } .ocsb-icon-btn svg { width: 17px; height: 17px; } -#ocsb-rail-toggle { margin-right: -2px; } /* Messages */ -#ocsb-messages { flex: 1; overflow-y: auto; padding: 16px 14px 6px; scroll-behavior: smooth; } -#ocsb-messages::-webkit-scrollbar, #ocsb-convos::-webkit-scrollbar { width: 8px; } -#ocsb-messages::-webkit-scrollbar-thumb, #ocsb-convos::-webkit-scrollbar-thumb { background: var(--ocsb-border-strong); border-radius: 8px; } +#ocsb-messages { flex: 1; overflow-y: auto; padding: 18px 16px 8px; scroll-behavior: smooth; } +#ocsb-messages::-webkit-scrollbar, #ocsb-convos::-webkit-scrollbar { width: 9px; } +#ocsb-messages::-webkit-scrollbar-thumb, #ocsb-convos::-webkit-scrollbar-thumb { background: var(--ocsb-border-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } +#ocsb-messages::-webkit-scrollbar-thumb:hover { background: var(--ocsb-fg-faint); background-clip: padding-box; } -.ocsb-msg { display: flex; gap: 9px; margin-bottom: 14px; animation: ocsb-msg-in 260ms cubic-bezier(0.22,1,0.36,1); } -@keyframes ocsb-msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } -.ocsb-msg-avatar { flex: none; width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; font-size: 0.8rem; } -.ocsb-msg.bot .ocsb-msg-avatar { background: var(--ocsb-accent-soft); color: var(--ocsb-accent); } -.ocsb-msg.bot .ocsb-msg-avatar svg { width: 16px; height: 16px; } +.ocsb-msg { display: flex; gap: 10px; margin-bottom: 16px; animation: ocsb-msg-in 280ms var(--ocsb-ease); } +@keyframes ocsb-msg-in { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: none; } } +.ocsb-msg-avatar { flex: none; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; } +.ocsb-msg-avatar svg { width: 16px; height: 16px; } +.ocsb-msg.bot .ocsb-msg-avatar { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); border: 1px solid var(--ocsb-border-strong); } .ocsb-msg.user { flex-direction: row-reverse; } -.ocsb-msg.user .ocsb-msg-avatar { background: rgba(255,255,255,0.10); color: var(--ocsb-fg-muted); } +.ocsb-msg.user .ocsb-msg-avatar { background: var(--ocsb-surface-2); color: var(--ocsb-fg-muted); } .ocsb-bubble { - max-width: 80%; - padding: 10px 13px; - border-radius: var(--ocsb-radius-msg); - font-size: 0.875rem; - line-height: 1.55; - word-wrap: break-word; - overflow-wrap: anywhere; + max-width: 82%; padding: 11px 14px; font-size: 0.88rem; line-height: 1.62; + word-wrap: break-word; overflow-wrap: anywhere; +} +.ocsb-msg.bot .ocsb-bubble { + background: var(--ocsb-surface); color: var(--ocsb-fg); + border: 1px solid var(--ocsb-hairline); border-radius: var(--ocsb-r-msg); border-top-left-radius: 5px; + box-shadow: var(--ocsb-shadow-soft); +} +.ocsb-msg.user .ocsb-bubble { + background: var(--ocsb-brand-grad); color: #fff; + border-radius: var(--ocsb-r-msg); border-top-right-radius: 5px; + box-shadow: 0 6px 18px rgba(31, 116, 224, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2); } -.ocsb-msg.bot .ocsb-bubble { background: var(--ocsb-bg-bot); color: var(--ocsb-fg); border-top-left-radius: 5px; } -.ocsb-msg.user .ocsb-bubble { background: var(--ocsb-bg-user); color: #fff; border-top-right-radius: 5px; } /* Markdown inside bubbles */ -.ocsb-bubble p { margin: 0 0 8px; } .ocsb-bubble p:last-child { margin-bottom: 0; } -.ocsb-bubble h3, .ocsb-bubble h4 { margin: 6px 0 5px; font-size: 0.92rem; font-weight: 700; } -.ocsb-bubble ul, .ocsb-bubble ol { margin: 4px 0 8px; padding-left: 20px; } -.ocsb-bubble li { margin: 2px 0; } -.ocsb-bubble a { color: var(--ocsb-accent); text-decoration: underline; text-underline-offset: 2px; } +.ocsb-bubble p { margin: 0 0 9px; } .ocsb-bubble p:last-child { margin-bottom: 0; } +.ocsb-bubble h3, .ocsb-bubble h4 { margin: 8px 0 6px; font-size: 0.92rem; font-weight: 700; letter-spacing: -0.01em; } +.ocsb-bubble ul, .ocsb-bubble ol { margin: 5px 0 9px; padding-left: 20px; } +.ocsb-bubble li { margin: 3px 0; } +.ocsb-bubble li::marker { color: var(--ocsb-brand); } +.ocsb-bubble a { color: var(--ocsb-brand); text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(76,175,239,0.4); } +.ocsb-bubble a:hover { text-decoration-color: var(--ocsb-brand); } .ocsb-msg.user .ocsb-bubble a { color: #eaf3ff; } -.ocsb-bubble code { font-family: var(--ocsb-mono); font-size: 0.82em; background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 5px; } -.ocsb-bubble pre { position: relative; background: var(--ocsb-bg-code); border: 1px solid var(--ocsb-border); border-radius: 10px; padding: 11px 12px; margin: 7px 0; overflow-x: auto; } -.ocsb-bubble pre code { background: none; padding: 0; font-size: 0.8rem; line-height: 1.5; color: #d7e3f2; } +.ocsb-bubble code { font-family: var(--ocsb-mono); font-size: 0.82em; background: rgba(0, 0, 0, 0.35); padding: 1.5px 5px; border-radius: 5px; border: 1px solid var(--ocsb-hairline); } +.ocsb-bubble pre { position: relative; background: var(--ocsb-code); border: 1px solid var(--ocsb-border); border-radius: 11px; padding: 12px 13px; margin: 9px 0; overflow-x: auto; box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); } +.ocsb-bubble pre code { background: none; border: none; padding: 0; font-size: 0.8rem; line-height: 1.55; color: #cfe0f2; } .ocsb-bubble pre .ocsb-copy { - position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 0.68rem; font-weight: 600; - border: 1px solid var(--ocsb-border-strong); border-radius: 6px; background: rgba(255,255,255,0.05); - color: var(--ocsb-fg-muted); cursor: pointer; opacity: 0; transition: opacity 140ms ease; + position: absolute; top: 7px; right: 7px; display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px; font-size: 0.66rem; font-weight: 600; font-family: var(--ocsb-font); + border: 1px solid var(--ocsb-border-strong); border-radius: 6px; background: rgba(255, 255, 255, 0.05); + color: var(--ocsb-fg-muted); cursor: pointer; opacity: 0; transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease; } .ocsb-bubble pre:hover .ocsb-copy { opacity: 1; } -.ocsb-bubble pre .ocsb-copy:hover { color: var(--ocsb-fg); border-color: var(--ocsb-accent); } -.ocsb-bubble blockquote { border-left: 3px solid var(--ocsb-accent); padding-left: 10px; margin: 6px 0; color: var(--ocsb-fg-muted); } +.ocsb-bubble pre .ocsb-copy:hover { color: var(--ocsb-fg); border-color: var(--ocsb-brand); } +.ocsb-bubble blockquote { border-left: 2px solid var(--ocsb-brand); padding-left: 11px; margin: 7px 0; color: var(--ocsb-fg-muted); } -/* Navigation action chips under a bot message */ -.ocsb-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 9px; } +/* Navigation "go" chips (run-green) */ +.ocsb-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; } .ocsb-nav-chip { - display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; - border: 1px solid var(--ocsb-border-strong); border-radius: var(--ocsb-radius-chip); - background: var(--ocsb-bg-chip); color: var(--ocsb-accent); font-size: 0.78rem; font-weight: 600; - cursor: pointer; transition: background 140ms ease, transform 120ms ease, border-color 140ms ease; + display: inline-flex; align-items: center; gap: 7px; padding: 8px 13px; + border: 1px solid var(--ocsb-go-border); border-radius: var(--ocsb-r-pill); + background: var(--ocsb-go-soft); color: var(--ocsb-go-text); font-size: 0.78rem; font-weight: 600; + cursor: pointer; transition: background 160ms ease, transform 140ms var(--ocsb-ease), box-shadow 160ms ease; } -.ocsb-nav-chip:hover { background: var(--ocsb-bg-chip-hover); border-color: var(--ocsb-accent); transform: translateY(-1px); } +.ocsb-nav-chip:hover { background: rgba(52, 211, 153, 0.2); transform: translateY(-1px); box-shadow: 0 6px 16px rgba(52, 211, 153, 0.18); } .ocsb-nav-chip svg { width: 13px; height: 13px; } /* Typing indicator */ -.ocsb-typing { display: inline-flex; gap: 4px; align-items: center; padding: 4px 2px; } -.ocsb-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-fg-faint); animation: ocsb-typing 1.2s infinite ease-in-out; } +.ocsb-typing { display: inline-flex; gap: 4px; align-items: center; padding: 3px 2px; } +.ocsb-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-brand); opacity: 0.5; animation: ocsb-typing 1.2s infinite ease-in-out; } .ocsb-typing span:nth-child(2) { animation-delay: 0.18s; } .ocsb-typing span:nth-child(3) { animation-delay: 0.36s; } -@keyframes ocsb-typing { 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; } 30% { transform: translateY(-5px); opacity: 1; } } +@keyframes ocsb-typing { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-5px); opacity: 1; } } /* Welcome / empty state */ -#ocsb-welcome { padding: 20px 16px 8px; } -.ocsb-welcome-hero { text-align: center; margin-bottom: 16px; } -.ocsb-welcome-badge { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 16px; background: var(--ocsb-bg-fab); color: #fff; margin-bottom: 12px; box-shadow: 0 8px 24px rgba(17,103,214,0.4); } -.ocsb-welcome-badge svg { width: 28px; height: 28px; } -.ocsb-welcome-hero h3 { font-size: 1.18rem; font-weight: 800; letter-spacing: -0.02em; color: var(--ocsb-fg); } -.ocsb-welcome-hero p { font-size: 0.85rem; color: var(--ocsb-fg-muted); margin-top: 5px; line-height: 1.5; } -.ocsb-suggest-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ocsb-fg-faint); margin: 6px 4px 8px; } +#ocsb-welcome { padding: 26px 18px 10px; } +.ocsb-welcome-hero { text-align: center; margin-bottom: 20px; } +.ocsb-welcome-badge { + display: inline-grid; place-items: center; width: 58px; height: 58px; border-radius: 18px; + background: var(--ocsb-brand-grad); color: #fff; margin-bottom: 14px; + box-shadow: 0 14px 34px rgba(31, 116, 224, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.3); +} +.ocsb-welcome-badge svg { width: 30px; height: 30px; } +.ocsb-welcome-hero h3 { font-size: 1.22rem; font-weight: 800; letter-spacing: -0.025em; color: var(--ocsb-fg); } +.ocsb-welcome-hero p { font-size: 0.85rem; color: var(--ocsb-fg-muted); margin-top: 7px; line-height: 1.6; max-width: 33ch; margin-left: auto; margin-right: auto; } +.ocsb-suggest-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ocsb-fg-faint); margin: 4px 4px 10px; } #ocsb-suggestions { display: flex; flex-direction: column; gap: 8px; } .ocsb-suggestion { - display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 13px; text-align: left; - border: 1px solid var(--ocsb-border); border-radius: 12px; background: var(--ocsb-bg-panel-2); - color: var(--ocsb-fg); font-size: 0.83rem; cursor: pointer; transition: border-color 150ms ease, background 150ms ease, transform 120ms ease; + display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 13px; text-align: left; + border: 1px solid var(--ocsb-hairline); border-radius: var(--ocsb-r-card); background: var(--ocsb-surface); + color: var(--ocsb-fg); font-size: 0.83rem; font-weight: 500; cursor: pointer; + transition: border-color 180ms ease, background 180ms ease, transform 140ms var(--ocsb-ease); +} +.ocsb-suggestion:hover { border-color: var(--ocsb-border-strong); background: var(--ocsb-surface-2); transform: translateX(2px); } +.ocsb-suggestion .ocsb-suggestion-ico { + flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; + background: var(--ocsb-brand-soft); color: var(--ocsb-brand); } -.ocsb-suggestion:hover { border-color: var(--ocsb-accent); background: var(--ocsb-bg-bot); transform: translateY(-1px); } -.ocsb-suggestion .ocsb-suggestion-ico { flex: none; font-size: 1.05rem; } +.ocsb-suggestion .ocsb-suggestion-ico svg { width: 16px; height: 16px; } .ocsb-suggestion span.ocsb-suggestion-txt { flex: 1; } +.ocsb-suggestion .ocsb-suggestion-go { flex: none; color: var(--ocsb-fg-faint); opacity: 0; transform: translateX(-4px); transition: opacity 160ms ease, transform 160ms ease; } +.ocsb-suggestion .ocsb-suggestion-go svg { width: 15px; height: 15px; } +.ocsb-suggestion:hover .ocsb-suggestion-go { opacity: 1; transform: translateX(0); } /* Follow-up chips above composer */ -#ocsb-followups { display: flex; flex-wrap: wrap; gap: 7px; padding: 0 14px 8px; } +#ocsb-followups { display: flex; flex-wrap: wrap; gap: 7px; padding: 0 16px 9px; } #ocsb-followups[hidden] { display: none; } .ocsb-followup { - padding: 6px 11px; border: 1px solid var(--ocsb-border); border-radius: var(--ocsb-radius-chip); - background: transparent; color: var(--ocsb-fg-muted); font-size: 0.76rem; cursor: pointer; transition: all 140ms ease; + display: inline-flex; align-items: center; gap: 5px; padding: 7px 12px; + border: 1px solid var(--ocsb-border); border-radius: var(--ocsb-r-pill); + background: var(--ocsb-surface); color: var(--ocsb-fg-muted); font-size: 0.76rem; font-weight: 500; + cursor: pointer; transition: all 160ms ease; } -.ocsb-followup:hover { color: var(--ocsb-fg); border-color: var(--ocsb-accent); background: var(--ocsb-bg-chip); } +.ocsb-followup:hover { color: var(--ocsb-go-text); border-color: var(--ocsb-go-border); background: var(--ocsb-go-soft); } /* Composer */ -#ocsb-form { padding: 10px 12px 12px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-panel); } -.ocsb-composer-row { display: flex; align-items: flex-end; gap: 8px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border-strong); border-radius: 14px; padding: 6px 6px 6px 12px; transition: border-color 150ms ease, box-shadow 150ms ease; } -.ocsb-composer-row:focus-within { border-color: var(--ocsb-accent); box-shadow: 0 0 0 3px var(--ocsb-accent-soft); } -#ocsb-input { flex: 1; resize: none; max-height: 120px; border: none; background: none; color: var(--ocsb-fg); font-size: 0.875rem; line-height: 1.45; padding: 6px 0; outline: none; } +#ocsb-form { padding: 11px 14px 13px; border-top: 1px solid var(--ocsb-hairline); } +.ocsb-composer-row { + display: flex; align-items: flex-end; gap: 8px; background: rgba(0, 0, 0, 0.28); + border: 1px solid var(--ocsb-border-strong); border-radius: 16px; padding: 7px 7px 7px 14px; + transition: border-color 180ms ease, box-shadow 180ms ease; +} +.ocsb-composer-row:focus-within { border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-soft); } +#ocsb-input { flex: 1; resize: none; max-height: 120px; border: none; background: none; color: var(--ocsb-fg); font-size: 0.88rem; line-height: 1.5; padding: 7px 0; outline: none; } #ocsb-input::placeholder { color: var(--ocsb-fg-faint); } #ocsb-send { - flex: none; display: grid; place-items: center; width: 36px; height: 36px; border: none; border-radius: 10px; - background: var(--ocsb-bg-fab); color: #fff; cursor: pointer; transition: transform 130ms ease, opacity 150ms ease; + flex: none; display: grid; place-items: center; width: 38px; height: 38px; border: none; border-radius: 11px; + background: var(--ocsb-brand-grad); color: #fff; cursor: pointer; + box-shadow: 0 4px 12px rgba(31, 116, 224, 0.4), inset 0 1px 0 rgba(255,255,255,0.25); + transition: transform 150ms var(--ocsb-ease), opacity 180ms ease, filter 180ms ease; } #ocsb-send:hover:not(:disabled) { transform: scale(1.06); } -#ocsb-send:disabled { opacity: 0.4; cursor: not-allowed; } +#ocsb-send:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; filter: grayscale(0.3); } #ocsb-send svg { width: 17px; height: 17px; } -.ocsb-composer-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 7px; padding: 0 2px; } +.ocsb-composer-foot { display: flex; align-items: center; justify-content: center; margin-top: 9px; } .ocsb-composer-hint { font-size: 0.68rem; color: var(--ocsb-fg-faint); } .ocsb-composer-hint b { color: var(--ocsb-fg-muted); font-weight: 600; } /* Settings drawer */ -#ocsb-settings { padding: 14px 16px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-rail); } +#ocsb-settings { padding: 16px; border-top: 1px solid var(--ocsb-hairline); background: var(--ocsb-rail); } #ocsb-settings[hidden] { display: none; } -.ocsb-set-row { margin-bottom: 14px; } +.ocsb-set-row { margin-bottom: 15px; } .ocsb-set-row:last-child { margin-bottom: 0; } -.ocsb-set-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ocsb-fg-faint); margin-bottom: 7px; } -.ocsb-segmented { display: flex; gap: 4px; background: var(--ocsb-bg-input); padding: 4px; border-radius: 10px; border: 1px solid var(--ocsb-border); } -.ocsb-segmented button { flex: 1; padding: 7px 6px; border: none; border-radius: 7px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: background 140ms ease, color 140ms ease; } -.ocsb-segmented button.is-active { background: var(--ocsb-accent-soft); color: var(--ocsb-accent); } -.ocsb-set-account { display: flex; align-items: center; gap: 9px; padding: 10px 11px; border-radius: 10px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border); font-size: 0.78rem; color: var(--ocsb-fg-muted); } +.ocsb-set-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ocsb-fg-faint); margin-bottom: 8px; } +.ocsb-segmented { display: flex; gap: 4px; background: rgba(0, 0, 0, 0.28); padding: 4px; border-radius: 11px; border: 1px solid var(--ocsb-border); } +.ocsb-segmented button { flex: 1; padding: 8px 6px; border: none; border-radius: 8px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: background 160ms ease, color 160ms ease; } +.ocsb-segmented button.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); box-shadow: inset 0 0 0 1px var(--ocsb-border-strong); } +.ocsb-set-account { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border-radius: 11px; background: rgba(0, 0, 0, 0.25); border: 1px solid var(--ocsb-border); font-size: 0.78rem; color: var(--ocsb-fg-muted); line-height: 1.5; } .ocsb-set-account .ocsb-dot2 { width: 8px; height: 8px; border-radius: 50%; flex: none; } -.ocsb-danger-btn { width: 100%; padding: 9px; border: 1px solid rgba(255,92,108,0.35); border-radius: 10px; background: rgba(255,92,108,0.08); color: var(--ocsb-err); font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: background 140ms ease; } -.ocsb-danger-btn:hover { background: rgba(255,92,108,0.16); } +.ocsb-danger-btn { width: 100%; padding: 10px; border: 1px solid rgba(255, 107, 120, 0.32); border-radius: 11px; background: rgba(255, 107, 120, 0.08); color: var(--ocsb-err); font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: background 160ms ease; } +.ocsb-danger-btn:hover { background: rgba(255, 107, 120, 0.16); } -/* Error / toast line */ -.ocsb-error { margin: 0 14px 10px; padding: 9px 12px; border-radius: 10px; background: rgba(255,92,108,0.10); border: 1px solid rgba(255,92,108,0.30); color: #ffd5da; font-size: 0.78rem; } +/* Error line */ +.ocsb-error { margin: 0 16px 10px; padding: 10px 13px; border-radius: 11px; background: rgba(255, 107, 120, 0.1); border: 1px solid rgba(255, 107, 120, 0.3); color: #ffd5da; font-size: 0.78rem; } /* ============================ Mobile / responsive ============================ */ @media (max-width: 560px) { @@ -387,26 +380,20 @@ body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: #ocsb-fab { padding: 13px; right: 16px; bottom: 16px; } body.ocsb-open #ocsb-panel, body.ocsb-open.ocsb-expanded #ocsb-panel { - right: 0; bottom: 0; left: 0; top: 0; - width: 100vw; height: 100vh; height: 100dvh; - border-radius: 0; - grid-template-columns: 0fr 1fr; + right: 0; bottom: 0; left: 0; top: 0; width: 100vw; height: 100vh; height: 100dvh; + border-radius: 0; grid-template-columns: 0fr 1fr; } - /* On phones the rail overlays instead of pushing */ body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 0fr 1fr; } body.ocsb-open.ocsb-rail-open #ocsb-rail { - position: absolute; top: 0; bottom: 0; left: 0; width: 76%; max-width: 280px; z-index: 5; - box-shadow: 24px 0 60px rgba(0,0,0,0.6); + position: absolute; top: 0; bottom: 0; left: 0; width: 78%; max-width: 290px; z-index: 5; + box-shadow: 24px 0 60px rgba(0, 0, 0, 0.6); } - .ocsb-bubble { max-width: 86%; } + .ocsb-bubble { max-width: 88%; } } -/* Light-site fallback: site is dark, but if ever rendered on a light bg, - the panel keeps its own dark surfaces (intentional, self-contained). */ - /* Reduced motion */ @media (prefers-reduced-motion: reduce) { #ocsb-fab, #ocsb-panel, .ocsb-msg, .ocsb-nav-chip, #ocsb-send, .ocsb-suggestion { transition: none !important; animation: none !important; } - .ocsb-fab-pulse, .ocsb-status-dot, .ocsb-typing span { animation: none !important; } + .ocsb-fab-pulse, .ocsb-typing span { animation: none !important; } #ocsb-messages { scroll-behavior: auto; } } diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js index 5b7f53bd90..0ac5891b4e 100644 --- a/assets/js/ocs-bot/index.js +++ b/assets/js/ocs-bot/index.js @@ -17,10 +17,31 @@ import { DEFAULT_MODEL } from './config.js'; const $ = (id) => document.getElementById(id); const HISTORY_TURNS = 16; // messages of context sent to the model -const BOT_AVATAR = - ''; -const ARROW = - ''; +const SVG = (body, w = 2) => + ``; + +const BOT_AVATAR = SVG(''); +const USER_AVATAR = SVG(''); +const ARROW = SVG('', 2.4); +const CHEVRON = SVG('', 2.2); + +// Monoline SVG icon set for suggestion chips (keyed by ids in suggestions.js). +const ICONS = { + compass: SVG(''), + book: SVG(''), + java: SVG(''), + gamepad: SVG(''), + layers: SVG(''), + grid: SVG(''), + trophy: SVG(''), + search: SVG(''), + calendar: SVG(''), + rocket: SVG(''), + cap: SVG(''), + terminal: SVG(''), + user: USER_AVATAR, + spark: SVG(''), +}; const bot = { el: {}, @@ -258,7 +279,7 @@ function renderSuggestions() { b.type = 'button'; b.className = 'ocsb-suggestion'; b.dataset.text = s.text; - b.innerHTML = `${escapeText(s.text)}`; + b.innerHTML = `${escapeText(s.text)}`; wrap.appendChild(b); }); } @@ -309,7 +330,7 @@ function renderActiveConversation() { function addUserBubble(text) { const node = document.createElement('div'); node.className = 'ocsb-msg user'; - node.innerHTML = `
    `; + node.innerHTML = `${USER_AVATAR}
    `; node.querySelector('.ocsb-bubble').textContent = text; bot.el['ocsb-messages'].appendChild(node); return node; diff --git a/assets/js/ocs-bot/suggestions.js b/assets/js/ocs-bot/suggestions.js index 96f0641e36..880469b971 100644 --- a/assets/js/ocs-bot/suggestions.js +++ b/assets/js/ocs-bot/suggestions.js @@ -1,28 +1,29 @@ // assets/js/ocs-bot/suggestions.js // ----------------------------------------------------------------------------- // Curated starter prompts for the welcome screen. Each becomes a clickable -// chip that pre-fills + sends. Tuned to what real OCS visitors ask. +// card that pre-fills + sends. `icon` is an SVG id resolved in index.js +// (no emoji — crisp monoline icons for a premium look). // ----------------------------------------------------------------------------- const POOL = [ - { icon: '🧭', text: "Which course should I start with?" }, - { icon: '📚', text: "What does the AP CSP course cover?" }, - { icon: '☕', text: "Take me to the AP CSA Java lessons" }, - { icon: '🎮', text: "How do I build a game in CSSE?" }, - { icon: '🔌', text: "Explain the full-stack: frontend, Flask, and database" }, - { icon: '🧩', text: "Where are the coding 'hacks' and projects?" }, - { icon: '🏆', text: "Show me the leaderboard" }, - { icon: '🔍', text: "How do I search the site?" }, - { icon: '🗓️', text: "Where's the course calendar?" }, - { icon: '🚀', text: "What is 'Night at the Museum'?" }, - { icon: '🧠', text: "Help me prep for the AP CSA exam" }, - { icon: '🔐', text: "Open the Linux CTF challenges" }, + { icon: 'compass', text: 'Which course should I start with?' }, + { icon: 'book', text: 'What does the AP CSP course cover?' }, + { icon: 'java', text: 'Take me to the AP CSA Java lessons' }, + { icon: 'gamepad', text: 'How do I build a game in CSSE?' }, + { icon: 'layers', text: 'Explain the full-stack: frontend, Flask, and database' }, + { icon: 'grid', text: "Where are the coding 'hacks' and projects?" }, + { icon: 'trophy', text: 'Show me the leaderboard' }, + { icon: 'search', text: 'How do I search the site?' }, + { icon: 'calendar', text: "Where's the course calendar?" }, + { icon: 'rocket', text: "What is 'Night at the Museum'?" }, + { icon: 'cap', text: 'Help me prep for the AP CSA exam' }, + { icon: 'terminal', text: 'Open the Linux CTF challenges' }, ]; export function starterSuggestions(user) { const picks = []; - if (!user) picks.push({ icon: '👤', text: 'How do I sign up for an OCS account?' }); - // Stable-ish rotation so it doesn't feel random within a session. + if (!user) picks.push({ icon: 'user', text: 'How do I sign up for an OCS account?' }); + // Stable rotation within a session (don't reshuffle on every render). const start = (new Date().getHours() * 3) % POOL.length; for (let i = 0; picks.length < 5 && i < POOL.length; i++) { picks.push(POOL[(start + i) % POOL.length]); From a75926d3897467be9eb41896099417d3d8958efb Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 13:58:10 -0700 Subject: [PATCH 03/11] Add configurable nav-link target for the OCS Assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navigation chips honor an optional window.OCS_BOT_CONFIG.navTarget: '_self' (default — same-tab, correct for the real same-origin site) or '_blank' (new tab). Lets local previews that serve unbuilt Jekyll source open links against the live site instead of dead-ending on a 404. - Production behavior unchanged (defaults to same-tab internal navigation). --- assets/js/ocs-bot/config.js | 5 +++++ assets/js/ocs-bot/index.js | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/assets/js/ocs-bot/config.js b/assets/js/ocs-bot/config.js index 349a4fa5b7..092d505556 100644 --- a/assets/js/ocs-bot/config.js +++ b/assets/js/ocs-bot/config.js @@ -40,3 +40,8 @@ export const FETCH_TIMEOUT = 45000; // Allow disabling the optional backend sync entirely. export const ENABLE_BACKEND_SYNC = overrides.backendSync !== false; + +// Where navigation chips open: '_self' (default — same tab, correct for the +// real same-origin site) or '_blank' (new tab; handy for local previews that +// serve unbuilt source). +export const NAV_TARGET = overrides.navTarget === '_blank' ? '_blank' : '_self'; diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js index 0ac5891b4e..88a3e3188e 100644 --- a/assets/js/ocs-bot/index.js +++ b/assets/js/ocs-bot/index.js @@ -12,7 +12,7 @@ import { renderMarkdown } from './render.js'; import { parseActions, hrefFor } from './tools.js'; import { starterSuggestions } from './suggestions.js'; import { NAV_INDEX } from './knowledge.js'; -import { DEFAULT_MODEL } from './config.js'; +import { DEFAULT_MODEL, NAV_TARGET } from './config.js'; const $ = (id) => document.getElementById(id); const HISTORY_TURNS = 16; // messages of context sent to the model @@ -161,7 +161,14 @@ function delegatedClick(e) { if (fu && bot.el['ocsb-panel'].contains(fu)) { bot.el['ocsb-input'].value = fu.dataset.text || fu.textContent.trim(); onInput(); submit(); return; } // Navigation chip const nav = e.target.closest('.ocsb-nav-chip'); - if (nav && bot.el['ocsb-panel'].contains(nav)) { const href = nav.getAttribute('data-href'); if (href) window.location.href = href; return; } + if (nav && bot.el['ocsb-panel'].contains(nav)) { + const href = nav.getAttribute('data-href'); + if (href) { + if (NAV_TARGET === '_blank') window.open(href, '_blank', 'noopener'); + else window.location.href = href; + } + return; + } // Copy code button const copy = e.target.closest('.ocsb-copy'); if (copy) { const code = copy.parentElement.querySelector('code'); copyText(code ? code.textContent : '', copy); return; } From 75da5fa32638c679fb1bc47a63fe410fc8a50cab Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 14:48:23 -0700 Subject: [PATCH 04/11] Rework OCS Assistant UI to match the PNEC bot's structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adopt the PNEC Helper Bot layout: a unified avatar-left message thread (avatar + body + bubble grid) instead of left/right chat bubbles, so short messages no longer clip and the thread reads cleanly like an AI assistant. - Compact, refined sizing (384x600 panel, 0.9rem bubbles, 16px radius), DM Sans, centered welcome hero with a trust-tips row, and pinned grid rows for a stable layout (welcome and transcript are mutually-exclusive scroll regions). - Fix: #ocsb-welcome/#ocsb-messages set display, which beat the [hidden] UA rule and made them overlap — added explicit [hidden] display:none. - Rebranded PNEC green to the OCS blue; nav chips use the brand accent. --- _includes/chatbot/ocs-bot.html | 41 ++- assets/css/ocs-bot.css | 521 ++++++++++++--------------------- assets/js/ocs-bot/index.js | 14 +- 3 files changed, 230 insertions(+), 346 deletions(-) diff --git a/_includes/chatbot/ocs-bot.html b/_includes/chatbot/ocs-bot.html index b3e8270b85..cf500ff629 100644 --- a/_includes/chatbot/ocs-bot.html +++ b/_includes/chatbot/ocs-bot.html @@ -63,20 +63,35 @@

    OCS Assistant

    -
    - -
    -
    - -

    Hey! I'm the OCS Assistant 👋

    -

    Ask me about courses, lessons, and projects — or tell me where you want to go and I'll take you there.

    -
    -

    Try asking

    -
    + +
    +
    + +

    Hey! I'm the OCS Assistant 👋

    +

    Ask me about courses, lessons, and projects — or tell me where to go and I'll take you there.

    -
    +

    Try asking

    +
    + + + + + diff --git a/assets/css/ocs-bot.css b/assets/css/ocs-bot.css index e9392fd93e..49086dc1d2 100644 --- a/assets/css/ocs-bot.css +++ b/assets/css/ocs-bot.css @@ -1,289 +1,211 @@ /* ============================================================================= OCS Assistant — global chatbot widget for pages.opencodingsociety.com - "Code-dark glass" design language: layered translucent surfaces with depth - (backdrop-blur), a refined slate palette, an OCS blue→cyan brand accent, and - green "go" affordances for navigation (code-dark + run-green). Everything is - namespaced .ocsb- / #ocsb- so it can never collide with site styles. Loads - below-the-fold (a fixed launcher), so there is no flash of unstyled content. + Structure & polish modeled on the PNEC Helper Bot (a fork of this same base): + a unified avatar-left message thread, compact bubbles, a centered welcome + hero with a trust row, and a side rail — rebranded to the OCS blue and + locked to a dark surface. Namespaced .ocsb-/#ocsb- so it never collides with + site styles; loads below-the-fold (fixed launcher), so no FOUC. ============================================================================= */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&display=swap'); :root { - /* ---- Surfaces (layered slate, glass) ---- */ - --ocsb-bg: #0a0f17; - --ocsb-panel: rgba(15, 21, 32, 0.82); - --ocsb-panel-solid: #0e1521; - --ocsb-rail: rgba(9, 13, 20, 0.7); - --ocsb-surface: rgba(255, 255, 255, 0.035); - --ocsb-surface-2: rgba(255, 255, 255, 0.06); - --ocsb-surface-3: rgba(255, 255, 255, 0.09); - --ocsb-code: #070b12; - - /* ---- Text ---- */ - --ocsb-fg: #eaf1fa; - --ocsb-fg-muted: #9bacbe; - --ocsb-fg-faint: #6a7889; - --ocsb-fg-on-accent: #ffffff; - - /* ---- Brand (OCS blue → cyan) ---- */ - --ocsb-brand: #4cafef; - --ocsb-brand-2: #38bdf8; - --ocsb-brand-cyan: #22d3ee; - --ocsb-brand-deep: #1f74e0; - --ocsb-brand-grad: linear-gradient(135deg, #56b6f4 0%, #2f7ff0 100%); - --ocsb-brand-soft: rgba(76, 175, 239, 0.14); - --ocsb-brand-glow: rgba(56, 130, 240, 0.5); - - /* ---- "Go" / navigate (run-green) ---- */ - --ocsb-go: #34d399; - --ocsb-go-text: #5ee9b5; - --ocsb-go-soft: rgba(52, 211, 153, 0.12); - --ocsb-go-border: rgba(52, 211, 153, 0.34); - - /* ---- Lines ---- */ - --ocsb-border: rgba(168, 195, 224, 0.10); - --ocsb-border-strong: rgba(168, 195, 224, 0.18); - --ocsb-hairline: rgba(255, 255, 255, 0.06); - - /* ---- Status ---- */ - --ocsb-ok: #34d399; - --ocsb-err: #ff6b78; - - /* ---- Shape / motion ---- */ - --ocsb-r-panel: 22px; - --ocsb-r-card: 16px; - --ocsb-r-msg: 15px; - --ocsb-r-pill: 999px; - --ocsb-ease: cubic-bezier(0.22, 1, 0.36, 1); - --ocsb-shadow-fab: 0 12px 32px rgba(20, 90, 200, 0.40), 0 2px 6px rgba(0, 0, 0, 0.4); - --ocsb-shadow-panel: - 0 32px 80px -12px rgba(0, 0, 0, 0.7), - 0 0 0 1px var(--ocsb-border), - inset 0 1px 0 rgba(255, 255, 255, 0.06); - --ocsb-shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.28); - --ocsb-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - --ocsb-mono: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, Menlo, Consolas, monospace; - - /* ---- Layering (above OCS nav/footer) ---- */ - --ocsb-z-fab: 99990; - --ocsb-z-backdrop: 99994; - --ocsb-z-panel: 99995; + --ocsb-bg-fab: linear-gradient(135deg, #56b6f4 0%, #2f7ff0 100%); + --ocsb-bg-panel: #121823; /* docked panel surface */ + --ocsb-bg-rail: #0d131c; /* sidebar */ + --ocsb-bg-glass: #1a2433; /* header + cards, a touch lighter */ + --ocsb-bg-input: rgba(255, 255, 255, 0.06); + --ocsb-bg-bot-msg: rgba(255, 255, 255, 0.055); + --ocsb-bg-user-msg: linear-gradient(135deg, #4cafef 0%, #2f7ff0 100%); + --ocsb-fg: #eef4fb; + --ocsb-fg-muted: #9fb1c4; + --ocsb-fg-faint: #6f7f90; + --ocsb-fg-on-accent: #ffffff; + --ocsb-fg-link: #8fd0ff; + --ocsb-border: rgba(255, 255, 255, 0.10); + --ocsb-border-strong: rgba(255, 255, 255, 0.18); + --ocsb-brand: #4cafef; + --ocsb-brand-deep: #2f7ff0; + --ocsb-brand-soft: rgba(76, 175, 239, 0.18); + --ocsb-brand-glow: rgba(76, 175, 239, 0.22); + --ocsb-red: #ff6b78; + --ocsb-red-soft: rgba(255, 107, 120, 0.14); + --ocsb-shadow-fab: 0 6px 16px rgba(20, 80, 200, 0.34), 0 14px 32px rgba(20, 80, 200, 0.24), 0 1px 0 rgba(255,255,255,0.22) inset; + --ocsb-shadow-panel: 0 1px 0 rgba(255,255,255,0.10) inset, 0 8px 22px rgba(0,0,0,0.45), 0 28px 56px rgba(0,0,0,0.55); + --ocsb-radius-panel: 20px; + --ocsb-radius-msg: 16px; + --ocsb-font: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --ocsb-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + --ocsb-z-fab: 99990; + --ocsb-z-backdrop: 99994; + --ocsb-z-panel: 99995; } -/* Scoped reset — protects the widget from inherited site rules. */ -#ocsb-fab, #ocsb-fab *, -#ocsb-panel, #ocsb-panel * { - box-sizing: border-box; - margin: 0; - padding: 0; - font-family: var(--ocsb-font); - -webkit-font-smoothing: antialiased; -} +#ocsb-fab, #ocsb-fab *, #ocsb-panel, #ocsb-panel * { box-sizing: border-box; font-family: var(--ocsb-font); } -/* ============================ Launcher (FAB) ============================ */ +/* ─── Launcher (FAB) ───────────────────────────────────────────── */ #ocsb-fab { - position: fixed; - right: 24px; - bottom: 24px; - z-index: var(--ocsb-z-fab); - display: inline-flex; - align-items: center; - gap: 11px; - padding: 12px 18px 12px 13px; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: var(--ocsb-r-pill); - background: var(--ocsb-brand-grad); - color: var(--ocsb-fg-on-accent); - font-size: 0.92rem; - font-weight: 700; - letter-spacing: -0.01em; - cursor: pointer; - box-shadow: var(--ocsb-shadow-fab), inset 0 1px 0 rgba(255, 255, 255, 0.25); - transition: transform 240ms var(--ocsb-ease), box-shadow 280ms ease, opacity 200ms ease; -} -#ocsb-fab:hover { transform: translateY(-2px); box-shadow: 0 16px 40px rgba(20, 90, 200, 0.5), inset 0 1px 0 rgba(255,255,255,0.28); } -#ocsb-fab:active { transform: translateY(0); } -#ocsb-fab:focus-visible { outline: 3px solid var(--ocsb-brand-cyan); outline-offset: 3px; } -#ocsb-fab .ocsb-fab-icon { - display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; - background: rgba(255, 255, 255, 0.2); -} -#ocsb-fab .ocsb-fab-icon svg { width: 17px; height: 17px; } -#ocsb-fab .ocsb-fab-pulse { - position: absolute; inset: -1px; border-radius: inherit; pointer-events: none; - box-shadow: 0 0 0 0 var(--ocsb-brand-glow); animation: ocsb-pulse 3s ease-out infinite; + position: fixed; right: 22px; bottom: 22px; z-index: var(--ocsb-z-fab); + display: inline-flex; align-items: center; gap: 9px; + margin: 0; padding: 12px 17px 12px 13px; + border: 0; border-radius: 999px; + font-size: 0.9rem; font-weight: 700; letter-spacing: -0.01em; + color: var(--ocsb-fg-on-accent); background: var(--ocsb-bg-fab); + cursor: pointer; box-shadow: var(--ocsb-shadow-fab); + transition: transform 200ms cubic-bezier(.22,1,.36,1), box-shadow 280ms ease, opacity 180ms ease; } -@keyframes ocsb-pulse { - 0% { box-shadow: 0 0 0 0 var(--ocsb-brand-glow); } - 60% { box-shadow: 0 0 0 12px rgba(56, 130, 240, 0); } - 100% { box-shadow: 0 0 0 0 rgba(56, 130, 240, 0); } -} -body.ocsb-open #ocsb-fab { transform: scale(0.6); opacity: 0; pointer-events: none; } +#ocsb-fab:hover { transform: translateY(-2px) scale(1.02); box-shadow: 0 8px 22px rgba(20,80,200,0.46), 0 18px 40px rgba(20,80,200,0.34), 0 1px 0 rgba(255,255,255,0.3) inset; } +#ocsb-fab:active { transform: translateY(0) scale(0.98); } +#ocsb-fab:focus-visible { outline: 3px solid #fff; outline-offset: 3px; } +#ocsb-fab .ocsb-fab-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,0.2); } +#ocsb-fab .ocsb-fab-icon svg { width: 16px; height: 16px; } +#ocsb-fab .ocsb-fab-pulse { position: absolute; inset: -1px; border-radius: inherit; box-shadow: 0 0 0 0 var(--ocsb-brand-glow); animation: ocsb-pulse 3s ease-out infinite; pointer-events: none; } +@keyframes ocsb-pulse { 0% { box-shadow: 0 0 0 0 var(--ocsb-brand-glow); } 60% { box-shadow: 0 0 0 12px rgba(76,175,239,0); } 100% { box-shadow: 0 0 0 0 rgba(76,175,239,0); } } +body.ocsb-open #ocsb-fab { transform: scale(0); opacity: 0; pointer-events: none; } -/* ============================ Backdrop ============================ */ -#ocsb-backdrop { - position: fixed; inset: 0; z-index: var(--ocsb-z-backdrop); - background: rgba(4, 7, 12, 0.6); backdrop-filter: blur(2px); - opacity: 0; visibility: hidden; transition: opacity 240ms ease, visibility 240ms ease; -} +/* ─── Backdrop ─────────────────────────────────────────────────── */ +#ocsb-backdrop { position: fixed; inset: 0; z-index: var(--ocsb-z-backdrop); background: rgba(6,10,18,0.6); opacity: 0; visibility: hidden; transition: opacity 220ms ease, visibility 220ms ease; } body.ocsb-open.ocsb-modal #ocsb-backdrop { opacity: 1; visibility: visible; } -/* ============================ Panel ============================ */ +/* ─── Panel ────────────────────────────────────────────────────── */ #ocsb-panel { - position: fixed; right: 24px; bottom: 24px; z-index: var(--ocsb-z-panel); - width: 416px; height: min(660px, calc(100vh - 48px)); + position: fixed; right: 22px; bottom: 22px; z-index: var(--ocsb-z-panel); + width: 384px; height: 600px; max-height: calc(100vh - 48px); display: grid; grid-template-columns: 0fr 1fr; - background: var(--ocsb-panel); - -webkit-backdrop-filter: blur(28px) saturate(150%); - backdrop-filter: blur(28px) saturate(150%); - border-radius: var(--ocsb-r-panel); - box-shadow: var(--ocsb-shadow-panel); - overflow: hidden; - opacity: 0; visibility: hidden; - transform: translateY(18px) scale(0.97); transform-origin: bottom right; - transition: opacity 220ms ease, transform 260ms var(--ocsb-ease), visibility 220ms ease, width 280ms var(--ocsb-ease), height 280ms var(--ocsb-ease); - color: var(--ocsb-fg); -} -/* soft brand glow bleeding from the top — adds depth */ -#ocsb-panel::before { - content: ''; position: absolute; top: -40%; left: 50%; transform: translateX(-50%); - width: 120%; height: 220px; pointer-events: none; z-index: 0; - background: radial-gradient(60% 60% at 50% 0%, rgba(56, 130, 240, 0.18), transparent 70%); + background: var(--ocsb-bg-panel); border: 1px solid var(--ocsb-border); + border-radius: var(--ocsb-radius-panel); box-shadow: var(--ocsb-shadow-panel); + overflow: hidden; color: var(--ocsb-fg); + opacity: 0; visibility: hidden; transform: translateY(14px) scale(0.98); transform-origin: bottom right; + transition: opacity 200ms ease, transform 240ms cubic-bezier(.22,1,.36,1), visibility 200ms ease, width 260ms ease, height 260ms ease; } -body.ocsb-open #ocsb-panel { opacity: 1; visibility: visible; transform: translateY(0) scale(1); } -body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 176px 1fr; } -body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(760px, calc(100vw - 48px)); height: min(820px, calc(100vh - 48px)); } +body.ocsb-open #ocsb-panel { opacity: 1; visibility: visible; transform: none; } +body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 184px 1fr; } +body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(740px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); } body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: 232px 1fr; } -/* ---- Conversation rail ---- */ -#ocsb-rail { - position: relative; z-index: 1; - background: var(--ocsb-rail); border-right: 1px solid var(--ocsb-hairline); - display: flex; flex-direction: column; min-width: 0; overflow: hidden; -} -.ocsb-rail-head { padding: 14px 11px 8px; } +/* ─── Side rail ────────────────────────────────────────────────── */ +#ocsb-rail { background: var(--ocsb-bg-rail); border-right: 1px solid var(--ocsb-border); overflow: hidden; display: flex; flex-direction: column; min-width: 0; } +.ocsb-rail-head { padding: 14px 12px 8px; border-bottom: 1px solid var(--ocsb-border); } #ocsb-new { - width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 7px; - padding: 10px; border: 1px solid var(--ocsb-border-strong); border-radius: 12px; - background: var(--ocsb-surface-2); color: var(--ocsb-fg); font-size: 0.82rem; font-weight: 600; - cursor: pointer; transition: background 180ms ease, border-color 180ms ease; + display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: 100%; + padding: 9px 12px; font-size: 0.83rem; font-weight: 700; color: var(--ocsb-fg-on-accent); + background: var(--ocsb-bg-fab); border: 0; border-radius: 9px; cursor: pointer; + box-shadow: 0 1px 0 rgba(255,255,255,0.2) inset, 0 4px 10px rgba(20,80,200,0.25); + transition: transform 180ms ease, filter 180ms ease; } -#ocsb-new:hover { background: var(--ocsb-brand-soft); border-color: var(--ocsb-brand); } +#ocsb-new:hover { transform: translateY(-1px); filter: brightness(1.06); } #ocsb-new svg { width: 15px; height: 15px; } -.ocsb-rail-search { padding: 2px 11px 8px; } -.ocsb-rail-search input { - width: 100%; padding: 8px 11px; border: 1px solid var(--ocsb-border); border-radius: 10px; - background: rgba(0, 0, 0, 0.25); color: var(--ocsb-fg); font-size: 0.78rem; -} +.ocsb-rail-search { padding: 8px 12px; } +.ocsb-rail-search input { width: 100%; padding: 8px 10px; font-size: 0.82rem; color: var(--ocsb-fg); background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border); border-radius: 8px; -webkit-appearance: none; appearance: none; } .ocsb-rail-search input::placeholder { color: var(--ocsb-fg-faint); } -.ocsb-rail-search input:focus { outline: none; border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-soft); } -#ocsb-convos { list-style: none; flex: 1; overflow-y: auto; padding: 2px 9px 12px; } +.ocsb-rail-search input:focus { outline: none; border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-glow); } +#ocsb-convos { flex: 1; margin: 0; padding: 4px 8px 14px; list-style: none; overflow-y: auto; } #ocsb-convos li { margin-bottom: 2px; } -.ocsb-convo { - display: flex; align-items: center; gap: 7px; width: 100%; padding: 9px 10px; - border: 1px solid transparent; border-radius: 10px; background: transparent; - color: var(--ocsb-fg-muted); font-size: 0.8rem; text-align: left; cursor: pointer; - transition: background 160ms ease, color 160ms ease; -} -.ocsb-convo:hover { background: var(--ocsb-surface); color: var(--ocsb-fg); } -.ocsb-convo.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-fg); border-color: var(--ocsb-border-strong); } -.ocsb-convo-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ocsb-convo-del { - flex: none; display: none; width: 22px; height: 22px; border: none; border-radius: 7px; - background: transparent; color: var(--ocsb-fg-faint); cursor: pointer; font-size: 1rem; line-height: 1; -} -.ocsb-convo:hover .ocsb-convo-del { display: grid; place-items: center; } -.ocsb-convo-del:hover { color: var(--ocsb-err); background: rgba(255, 107, 120, 0.14); } -.ocsb-rail-empty { padding: 14px 12px; color: var(--ocsb-fg-faint); font-size: 0.74rem; line-height: 1.6; } +.ocsb-convo { display: grid; grid-template-columns: 1fr auto; gap: 6px; align-items: center; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.83rem; text-align: left; cursor: pointer; transition: background 140ms ease, color 140ms ease; } +.ocsb-convo:hover { background: rgba(76,175,239,0.08); color: var(--ocsb-fg); } +.ocsb-convo.is-active { background: rgba(76,175,239,0.16); color: var(--ocsb-fg); font-weight: 600; } +.ocsb-convo-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ocsb-convo-del { display: none; border: 0; background: transparent; color: var(--ocsb-fg-faint); cursor: pointer; padding: 2px; border-radius: 4px; font-size: 1rem; line-height: 1; } +.ocsb-convo:hover .ocsb-convo-del { display: inline-grid; place-items: center; } +.ocsb-convo-del:hover { color: var(--ocsb-red); background: var(--ocsb-red-soft); } +.ocsb-rail-empty { padding: 22px 16px; text-align: center; color: var(--ocsb-fg-faint); font-size: 0.8rem; line-height: 1.5; } -/* ---- Main column ---- */ -.ocsb-main { position: relative; z-index: 1; display: flex; flex-direction: column; min-width: 0; } +/* ─── Main column (pinned grid rows = stable layout) ───────────── */ +.ocsb-main { display: grid; grid-template-rows: auto auto 1fr auto auto auto; min-width: 0; min-height: 0; } +#ocsb-head { grid-row: 1; } +#ocsb-error { grid-row: 2; } +#ocsb-welcome, +#ocsb-messages { grid-row: 3; min-height: 0; } /* mutually exclusive scroll area */ +#ocsb-followups { grid-row: 4; } +#ocsb-settings { grid-row: 5; } +#ocsb-form { grid-row: 6; } +/* id selectors set display:flex, which beats the UA [hidden] rule — restore it */ +#ocsb-welcome[hidden], #ocsb-messages[hidden], #ocsb-error[hidden] { display: none !important; } /* Header */ -#ocsb-head { - display: flex; align-items: center; gap: 11px; padding: 14px 13px 13px; - border-bottom: 1px solid var(--ocsb-hairline); -} -.ocsb-head-avatar { - flex: none; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; - background: var(--ocsb-brand-grad); color: #fff; - box-shadow: 0 6px 16px rgba(31, 116, 224, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.3); -} -.ocsb-head-avatar svg { width: 21px; height: 21px; } +#ocsb-head { display: flex; align-items: center; gap: 10px; padding: 12px 13px; border-bottom: 1px solid var(--ocsb-border); background: var(--ocsb-bg-glass); } +.ocsb-head-avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; background: var(--ocsb-bg-fab); color: #fff; flex-shrink: 0; box-shadow: 0 1px 2px rgba(20,80,200,0.3); } +.ocsb-head-avatar svg { width: 19px; height: 19px; } .ocsb-head-meta { flex: 1; min-width: 0; } -.ocsb-head-meta h2 { font-size: 0.96rem; font-weight: 700; letter-spacing: -0.015em; color: var(--ocsb-fg); } -.ocsb-status { display: flex; align-items: center; gap: 6px; font-size: 0.72rem; color: var(--ocsb-fg-muted); margin-top: 2px; } -.ocsb-status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-ok); box-shadow: 0 0 8px var(--ocsb-ok); } +.ocsb-head-meta h2 { margin: 0; font-size: 0.98rem; font-weight: 800; letter-spacing: -0.01em; color: var(--ocsb-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ocsb-status { margin: 1px 0 0; font-size: 0.73rem; color: var(--ocsb-fg-muted); display: inline-flex; align-items: center; gap: 6px; } +.ocsb-status-dot { width: 7px; height: 7px; border-radius: 50%; background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.18); animation: ocsb-dot 2.6s ease-out infinite; } +@keyframes ocsb-dot { 0%,100% { transform: scale(1); } 50% { transform: scale(1.18); } } .ocsb-head-actions { display: flex; align-items: center; gap: 2px; } -.ocsb-icon-btn { - display: grid; place-items: center; width: 33px; height: 33px; border: none; border-radius: 9px; - background: transparent; color: var(--ocsb-fg-muted); cursor: pointer; - transition: background 160ms ease, color 160ms ease; +.ocsb-icon-btn { width: 32px; height: 32px; display: grid; place-items: center; background: transparent; border: 0; border-radius: 8px; color: var(--ocsb-fg-muted); cursor: pointer; transition: background 140ms ease, color 140ms ease; } +.ocsb-icon-btn:hover { background: rgba(76,175,239,0.12); color: var(--ocsb-brand); } +.ocsb-icon-btn:focus-visible { outline: 2px solid var(--ocsb-brand); outline-offset: 2px; } +.ocsb-icon-btn.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); } +.ocsb-icon-btn svg { width: 18px; height: 18px; } + +/* Error line */ +#ocsb-error { margin: 10px 14px 0; padding: 9px 12px; border-radius: 10px; background: var(--ocsb-red-soft); border: 1px solid rgba(255,107,120,0.3); color: #ffd5da; font-size: 0.78rem; } + +/* ─── Welcome / empty state ────────────────────────────────────── */ +#ocsb-welcome { padding: 26px 22px 16px; display: flex; flex-direction: column; align-items: center; text-align: center; overflow-y: auto; } +.ocsb-welcome-hero { margin-bottom: 16px; } +.ocsb-welcome-badge { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 15px; background: var(--ocsb-bg-fab); color: #fff; margin-bottom: 12px; box-shadow: 0 10px 26px rgba(20,80,200,0.4), inset 0 1px 0 rgba(255,255,255,0.3); } +.ocsb-welcome-badge svg { width: 27px; height: 27px; } +.ocsb-welcome-hero h3 { margin: 4px 0 4px; font-size: 1.28rem; font-weight: 800; letter-spacing: -0.022em; color: var(--ocsb-fg); } +.ocsb-welcome-hero p { margin: 0; font-size: 0.9rem; color: var(--ocsb-fg-muted); line-height: 1.5; max-width: 320px; } +.ocsb-suggest-label { align-self: flex-start; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ocsb-fg-faint); margin: 8px 2px 9px; } +#ocsb-suggestions { display: grid; gap: 7px; width: 100%; max-width: 380px; margin: 0 0 16px; align-content: start; grid-auto-rows: max-content; } +.ocsb-suggestion { + display: flex; align-items: center; gap: 10px; width: 100%; align-self: start; min-height: 44px; box-sizing: border-box; + padding: 10px 12px; font-size: 0.85rem; font-weight: 600; text-align: left; color: var(--ocsb-fg); + background: var(--ocsb-bg-glass); border: 1px solid var(--ocsb-border); border-radius: 11px; cursor: pointer; + transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 200ms ease; } -.ocsb-icon-btn:hover { background: var(--ocsb-surface-2); color: var(--ocsb-fg); } -.ocsb-icon-btn:focus-visible { outline: 2px solid var(--ocsb-brand); outline-offset: 1px; } -.ocsb-icon-btn svg { width: 17px; height: 17px; } +.ocsb-suggestion:hover { transform: translateY(-1px); background: rgba(76,175,239,0.08); border-color: rgba(76,175,239,0.3); box-shadow: 0 4px 10px rgba(20,80,200,0.12); } +.ocsb-suggestion-ico { width: 28px; height: 28px; flex-shrink: 0; display: grid; place-items: center; background: var(--ocsb-brand-soft); border-radius: 8px; color: var(--ocsb-brand); } +.ocsb-suggestion-ico svg { width: 16px; height: 16px; } +.ocsb-suggestion-txt { flex: 1; } +.ocsb-suggestion-go { color: var(--ocsb-fg-faint); flex-shrink: 0; display: grid; place-items: center; transition: color 160ms ease, transform 160ms ease; } +.ocsb-suggestion-go svg { width: 15px; height: 15px; } +.ocsb-suggestion:hover .ocsb-suggestion-go { color: var(--ocsb-brand); transform: translateX(2px); } +.ocsb-tips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 14px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--ocsb-border); width: 100%; } +.ocsb-tip { display: inline-flex; align-items: center; gap: 5px; font-size: 0.7rem; font-weight: 600; color: var(--ocsb-fg-faint); } +.ocsb-tip svg { width: 13px; height: 13px; } -/* Messages */ -#ocsb-messages { flex: 1; overflow-y: auto; padding: 18px 16px 8px; scroll-behavior: smooth; } -#ocsb-messages::-webkit-scrollbar, #ocsb-convos::-webkit-scrollbar { width: 9px; } +/* ─── Transcript (unified avatar-left thread) ──────────────────── */ +#ocsb-messages { padding: 18px 16px; overflow-y: auto; scroll-behavior: smooth; display: flex; flex-direction: column; gap: 14px; } +#ocsb-messages::-webkit-scrollbar, #ocsb-convos::-webkit-scrollbar, #ocsb-welcome::-webkit-scrollbar { width: 9px; } #ocsb-messages::-webkit-scrollbar-thumb, #ocsb-convos::-webkit-scrollbar-thumb { background: var(--ocsb-border-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } -#ocsb-messages::-webkit-scrollbar-thumb:hover { background: var(--ocsb-fg-faint); background-clip: padding-box; } -.ocsb-msg { display: flex; gap: 10px; margin-bottom: 16px; animation: ocsb-msg-in 280ms var(--ocsb-ease); } -@keyframes ocsb-msg-in { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: none; } } -.ocsb-msg-avatar { flex: none; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; } +.ocsb-msg { display: grid; grid-template-columns: 30px 1fr; gap: 10px; max-width: 100%; animation: ocsb-msg-in 220ms cubic-bezier(.2,.85,.25,1); } +@keyframes ocsb-msg-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } +.ocsb-msg-avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 9px; flex-shrink: 0; } .ocsb-msg-avatar svg { width: 16px; height: 16px; } -.ocsb-msg.bot .ocsb-msg-avatar { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); border: 1px solid var(--ocsb-border-strong); } -.ocsb-msg.user { flex-direction: row-reverse; } -.ocsb-msg.user .ocsb-msg-avatar { background: var(--ocsb-surface-2); color: var(--ocsb-fg-muted); } -.ocsb-bubble { - max-width: 82%; padding: 11px 14px; font-size: 0.88rem; line-height: 1.62; - word-wrap: break-word; overflow-wrap: anywhere; -} -.ocsb-msg.bot .ocsb-bubble { - background: var(--ocsb-surface); color: var(--ocsb-fg); - border: 1px solid var(--ocsb-hairline); border-radius: var(--ocsb-r-msg); border-top-left-radius: 5px; - box-shadow: var(--ocsb-shadow-soft); -} -.ocsb-msg.user .ocsb-bubble { - background: var(--ocsb-brand-grad); color: #fff; - border-radius: var(--ocsb-r-msg); border-top-right-radius: 5px; - box-shadow: 0 6px 18px rgba(31, 116, 224, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2); -} +.ocsb-msg.bot .ocsb-msg-avatar { background: var(--ocsb-bg-fab); color: #fff; box-shadow: 0 1px 2px rgba(20,80,200,0.3); } +.ocsb-msg.user .ocsb-msg-avatar { background: rgba(255,255,255,0.08); color: var(--ocsb-fg-muted); border: 1px solid var(--ocsb-border); } +.ocsb-msg-body { min-width: 0; } +.ocsb-bubble { width: fit-content; max-width: 100%; padding: 10px 14px; border-radius: var(--ocsb-radius-msg); font-size: 0.9rem; line-height: 1.58; color: var(--ocsb-fg); word-wrap: break-word; overflow-wrap: anywhere; } +.ocsb-msg.bot .ocsb-bubble { background: var(--ocsb-bg-bot-msg); border-top-left-radius: 5px; } +.ocsb-msg.user .ocsb-bubble { background: var(--ocsb-bg-user-msg); color: #fff; border-top-left-radius: 5px; box-shadow: 0 2px 8px rgba(20,80,200,0.28); } /* Markdown inside bubbles */ -.ocsb-bubble p { margin: 0 0 9px; } .ocsb-bubble p:last-child { margin-bottom: 0; } -.ocsb-bubble h3, .ocsb-bubble h4 { margin: 8px 0 6px; font-size: 0.92rem; font-weight: 700; letter-spacing: -0.01em; } -.ocsb-bubble ul, .ocsb-bubble ol { margin: 5px 0 9px; padding-left: 20px; } -.ocsb-bubble li { margin: 3px 0; } +.ocsb-bubble p { margin: 0 0 8px; } .ocsb-bubble p:last-child { margin: 0; } +.ocsb-bubble h3, .ocsb-bubble h4 { margin: 8px 0 5px; font-size: 0.92rem; font-weight: 800; letter-spacing: -0.01em; } +.ocsb-bubble strong { font-weight: 700; } +.ocsb-bubble ul, .ocsb-bubble ol { margin: 6px 0 8px; padding-left: 20px; } +.ocsb-bubble li { margin-bottom: 3px; } .ocsb-bubble li::marker { color: var(--ocsb-brand); } -.ocsb-bubble a { color: var(--ocsb-brand); text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(76,175,239,0.4); } -.ocsb-bubble a:hover { text-decoration-color: var(--ocsb-brand); } +.ocsb-bubble a { color: var(--ocsb-fg-link); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; text-decoration-color: rgba(143,208,255,0.4); } +.ocsb-bubble a:hover { text-decoration-color: var(--ocsb-fg-link); } .ocsb-msg.user .ocsb-bubble a { color: #eaf3ff; } -.ocsb-bubble code { font-family: var(--ocsb-mono); font-size: 0.82em; background: rgba(0, 0, 0, 0.35); padding: 1.5px 5px; border-radius: 5px; border: 1px solid var(--ocsb-hairline); } -.ocsb-bubble pre { position: relative; background: var(--ocsb-code); border: 1px solid var(--ocsb-border); border-radius: 11px; padding: 12px 13px; margin: 9px 0; overflow-x: auto; box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); } -.ocsb-bubble pre code { background: none; border: none; padding: 0; font-size: 0.8rem; line-height: 1.55; color: #cfe0f2; } -.ocsb-bubble pre .ocsb-copy { - position: absolute; top: 7px; right: 7px; display: inline-flex; align-items: center; gap: 4px; - padding: 3px 8px; font-size: 0.66rem; font-weight: 600; font-family: var(--ocsb-font); - border: 1px solid var(--ocsb-border-strong); border-radius: 6px; background: rgba(255, 255, 255, 0.05); - color: var(--ocsb-fg-muted); cursor: pointer; opacity: 0; transition: opacity 160ms ease, color 160ms ease, border-color 160ms ease; -} +.ocsb-bubble code { font-family: var(--ocsb-mono); font-size: 0.84em; background: rgba(0,0,0,0.34); padding: 2px 5px; border-radius: 5px; } +.ocsb-msg.user .ocsb-bubble code { background: rgba(255,255,255,0.2); color: #fff; } +.ocsb-bubble pre { position: relative; background: #070b12; border: 1px solid var(--ocsb-border); border-radius: 10px; padding: 11px 12px; margin: 8px 0; overflow-x: auto; } +.ocsb-bubble pre code { background: none; padding: 0; font-size: 0.8rem; line-height: 1.5; color: #cfe0f2; } +.ocsb-bubble pre .ocsb-copy { position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 0.66rem; font-weight: 600; border: 1px solid var(--ocsb-border-strong); border-radius: 6px; background: rgba(255,255,255,0.05); color: var(--ocsb-fg-muted); cursor: pointer; opacity: 0; transition: opacity 150ms ease, color 150ms ease, border-color 150ms ease; } .ocsb-bubble pre:hover .ocsb-copy { opacity: 1; } .ocsb-bubble pre .ocsb-copy:hover { color: var(--ocsb-fg); border-color: var(--ocsb-brand); } -.ocsb-bubble blockquote { border-left: 2px solid var(--ocsb-brand); padding-left: 11px; margin: 7px 0; color: var(--ocsb-fg-muted); } +.ocsb-bubble blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid var(--ocsb-brand); color: var(--ocsb-fg-muted); } -/* Navigation "go" chips (run-green) */ -.ocsb-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 11px; } -.ocsb-nav-chip { - display: inline-flex; align-items: center; gap: 7px; padding: 8px 13px; - border: 1px solid var(--ocsb-go-border); border-radius: var(--ocsb-r-pill); - background: var(--ocsb-go-soft); color: var(--ocsb-go-text); font-size: 0.78rem; font-weight: 600; - cursor: pointer; transition: background 160ms ease, transform 140ms var(--ocsb-ease), box-shadow 160ms ease; -} -.ocsb-nav-chip:hover { background: rgba(52, 211, 153, 0.2); transform: translateY(-1px); box-shadow: 0 6px 16px rgba(52, 211, 153, 0.18); } +/* Navigation chips (under a bot message) */ +.ocsb-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.ocsb-nav-chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; font-size: 0.78rem; font-weight: 700; color: var(--ocsb-brand); background: var(--ocsb-brand-soft); border: 1px solid rgba(76,175,239,0.34); border-radius: 999px; cursor: pointer; transition: background 150ms ease, transform 140ms ease, box-shadow 160ms ease; } +.ocsb-nav-chip:hover { background: rgba(76,175,239,0.28); transform: translateY(-1px); box-shadow: 0 5px 14px rgba(20,80,200,0.2); } .ocsb-nav-chip svg { width: 13px; height: 13px; } /* Typing indicator */ @@ -291,109 +213,54 @@ body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: .ocsb-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--ocsb-brand); opacity: 0.5; animation: ocsb-typing 1.2s infinite ease-in-out; } .ocsb-typing span:nth-child(2) { animation-delay: 0.18s; } .ocsb-typing span:nth-child(3) { animation-delay: 0.36s; } -@keyframes ocsb-typing { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-5px); opacity: 1; } } +@keyframes ocsb-typing { 0%,60%,100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-5px); opacity: 1; } } -/* Welcome / empty state */ -#ocsb-welcome { padding: 26px 18px 10px; } -.ocsb-welcome-hero { text-align: center; margin-bottom: 20px; } -.ocsb-welcome-badge { - display: inline-grid; place-items: center; width: 58px; height: 58px; border-radius: 18px; - background: var(--ocsb-brand-grad); color: #fff; margin-bottom: 14px; - box-shadow: 0 14px 34px rgba(31, 116, 224, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.3); -} -.ocsb-welcome-badge svg { width: 30px; height: 30px; } -.ocsb-welcome-hero h3 { font-size: 1.22rem; font-weight: 800; letter-spacing: -0.025em; color: var(--ocsb-fg); } -.ocsb-welcome-hero p { font-size: 0.85rem; color: var(--ocsb-fg-muted); margin-top: 7px; line-height: 1.6; max-width: 33ch; margin-left: auto; margin-right: auto; } -.ocsb-suggest-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ocsb-fg-faint); margin: 4px 4px 10px; } -#ocsb-suggestions { display: flex; flex-direction: column; gap: 8px; } -.ocsb-suggestion { - display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 13px; text-align: left; - border: 1px solid var(--ocsb-hairline); border-radius: var(--ocsb-r-card); background: var(--ocsb-surface); - color: var(--ocsb-fg); font-size: 0.83rem; font-weight: 500; cursor: pointer; - transition: border-color 180ms ease, background 180ms ease, transform 140ms var(--ocsb-ease); -} -.ocsb-suggestion:hover { border-color: var(--ocsb-border-strong); background: var(--ocsb-surface-2); transform: translateX(2px); } -.ocsb-suggestion .ocsb-suggestion-ico { - flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; - background: var(--ocsb-brand-soft); color: var(--ocsb-brand); -} -.ocsb-suggestion .ocsb-suggestion-ico svg { width: 16px; height: 16px; } -.ocsb-suggestion span.ocsb-suggestion-txt { flex: 1; } -.ocsb-suggestion .ocsb-suggestion-go { flex: none; color: var(--ocsb-fg-faint); opacity: 0; transform: translateX(-4px); transition: opacity 160ms ease, transform 160ms ease; } -.ocsb-suggestion .ocsb-suggestion-go svg { width: 15px; height: 15px; } -.ocsb-suggestion:hover .ocsb-suggestion-go { opacity: 1; transform: translateX(0); } - -/* Follow-up chips above composer */ -#ocsb-followups { display: flex; flex-wrap: wrap; gap: 7px; padding: 0 16px 9px; } +/* ─── Follow-up chips ──────────────────────────────────────────── */ +#ocsb-followups { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 16px 8px; align-content: start; } #ocsb-followups[hidden] { display: none; } -.ocsb-followup { - display: inline-flex; align-items: center; gap: 5px; padding: 7px 12px; - border: 1px solid var(--ocsb-border); border-radius: var(--ocsb-r-pill); - background: var(--ocsb-surface); color: var(--ocsb-fg-muted); font-size: 0.76rem; font-weight: 500; - cursor: pointer; transition: all 160ms ease; -} -.ocsb-followup:hover { color: var(--ocsb-go-text); border-color: var(--ocsb-go-border); background: var(--ocsb-go-soft); } +.ocsb-followup { align-self: start; display: inline-flex; align-items: center; gap: 5px; min-height: 32px; padding: 6px 12px; font-size: 0.76rem; font-weight: 600; color: var(--ocsb-fg-muted); background: var(--ocsb-bg-glass); border: 1px solid var(--ocsb-border); border-radius: 999px; cursor: pointer; transition: all 150ms ease; } +.ocsb-followup:hover { color: var(--ocsb-brand); border-color: rgba(76,175,239,0.34); background: rgba(76,175,239,0.1); } -/* Composer */ -#ocsb-form { padding: 11px 14px 13px; border-top: 1px solid var(--ocsb-hairline); } -.ocsb-composer-row { - display: flex; align-items: flex-end; gap: 8px; background: rgba(0, 0, 0, 0.28); - border: 1px solid var(--ocsb-border-strong); border-radius: 16px; padding: 7px 7px 7px 14px; - transition: border-color 180ms ease, box-shadow 180ms ease; -} -.ocsb-composer-row:focus-within { border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-soft); } -#ocsb-input { flex: 1; resize: none; max-height: 120px; border: none; background: none; color: var(--ocsb-fg); font-size: 0.88rem; line-height: 1.5; padding: 7px 0; outline: none; } +/* ─── Composer ─────────────────────────────────────────────────── */ +#ocsb-form { padding: 11px 14px 13px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-panel); } +.ocsb-composer-row { display: flex; align-items: flex-end; gap: 8px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border-strong); border-radius: 14px; padding: 6px 6px 6px 14px; transition: border-color 160ms ease, box-shadow 160ms ease; } +.ocsb-composer-row:focus-within { border-color: var(--ocsb-brand); box-shadow: 0 0 0 3px var(--ocsb-brand-glow); } +#ocsb-input { flex: 1; resize: none; max-height: 120px; border: 0; background: none; color: var(--ocsb-fg); font-size: 0.88rem; line-height: 1.45; padding: 6px 0; outline: none; } #ocsb-input::placeholder { color: var(--ocsb-fg-faint); } -#ocsb-send { - flex: none; display: grid; place-items: center; width: 38px; height: 38px; border: none; border-radius: 11px; - background: var(--ocsb-brand-grad); color: #fff; cursor: pointer; - box-shadow: 0 4px 12px rgba(31, 116, 224, 0.4), inset 0 1px 0 rgba(255,255,255,0.25); - transition: transform 150ms var(--ocsb-ease), opacity 180ms ease, filter 180ms ease; -} +#ocsb-send { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border: 0; border-radius: 10px; background: var(--ocsb-bg-fab); color: #fff; cursor: pointer; box-shadow: 0 3px 10px rgba(20,80,200,0.4), inset 0 1px 0 rgba(255,255,255,0.25); transition: transform 150ms ease, opacity 160ms ease, filter 160ms ease; } #ocsb-send:hover:not(:disabled) { transform: scale(1.06); } #ocsb-send:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; filter: grayscale(0.3); } #ocsb-send svg { width: 17px; height: 17px; } -.ocsb-composer-foot { display: flex; align-items: center; justify-content: center; margin-top: 9px; } +.ocsb-composer-foot { display: flex; align-items: center; justify-content: center; margin-top: 8px; } .ocsb-composer-hint { font-size: 0.68rem; color: var(--ocsb-fg-faint); } .ocsb-composer-hint b { color: var(--ocsb-fg-muted); font-weight: 600; } -/* Settings drawer */ -#ocsb-settings { padding: 16px; border-top: 1px solid var(--ocsb-hairline); background: var(--ocsb-rail); } +/* ─── Settings drawer ──────────────────────────────────────────── */ +#ocsb-settings { padding: 14px 16px; border-top: 1px solid var(--ocsb-border); background: var(--ocsb-bg-rail); } #ocsb-settings[hidden] { display: none; } -.ocsb-set-row { margin-bottom: 15px; } +.ocsb-set-row { margin-bottom: 14px; } .ocsb-set-row:last-child { margin-bottom: 0; } -.ocsb-set-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ocsb-fg-faint); margin-bottom: 8px; } -.ocsb-segmented { display: flex; gap: 4px; background: rgba(0, 0, 0, 0.28); padding: 4px; border-radius: 11px; border: 1px solid var(--ocsb-border); } -.ocsb-segmented button { flex: 1; padding: 8px 6px; border: none; border-radius: 8px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: background 160ms ease, color 160ms ease; } -.ocsb-segmented button.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); box-shadow: inset 0 0 0 1px var(--ocsb-border-strong); } -.ocsb-set-account { display: flex; align-items: center; gap: 9px; padding: 11px 12px; border-radius: 11px; background: rgba(0, 0, 0, 0.25); border: 1px solid var(--ocsb-border); font-size: 0.78rem; color: var(--ocsb-fg-muted); line-height: 1.5; } +.ocsb-set-label { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ocsb-fg-faint); margin-bottom: 7px; } +.ocsb-segmented { display: flex; gap: 4px; background: var(--ocsb-bg-input); padding: 4px; border-radius: 10px; border: 1px solid var(--ocsb-border); } +.ocsb-segmented button { flex: 1; padding: 8px 6px; border: 0; border-radius: 7px; background: transparent; color: var(--ocsb-fg-muted); font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: background 150ms ease, color 150ms ease; } +.ocsb-segmented button.is-active { background: var(--ocsb-brand-soft); color: var(--ocsb-brand); } +.ocsb-set-account { display: flex; align-items: center; gap: 9px; padding: 10px 11px; border-radius: 10px; background: var(--ocsb-bg-input); border: 1px solid var(--ocsb-border); font-size: 0.78rem; color: var(--ocsb-fg-muted); line-height: 1.5; } .ocsb-set-account .ocsb-dot2 { width: 8px; height: 8px; border-radius: 50%; flex: none; } -.ocsb-danger-btn { width: 100%; padding: 10px; border: 1px solid rgba(255, 107, 120, 0.32); border-radius: 11px; background: rgba(255, 107, 120, 0.08); color: var(--ocsb-err); font-size: 0.8rem; font-weight: 600; cursor: pointer; transition: background 160ms ease; } -.ocsb-danger-btn:hover { background: rgba(255, 107, 120, 0.16); } - -/* Error line */ -.ocsb-error { margin: 0 16px 10px; padding: 10px 13px; border-radius: 11px; background: rgba(255, 107, 120, 0.1); border: 1px solid rgba(255, 107, 120, 0.3); color: #ffd5da; font-size: 0.78rem; } +.ocsb-danger-btn { width: 100%; padding: 9px; border: 1px solid rgba(255,107,120,0.32); border-radius: 10px; background: var(--ocsb-red-soft); color: var(--ocsb-red); font-size: 0.8rem; font-weight: 700; cursor: pointer; transition: background 150ms ease; } +.ocsb-danger-btn:hover { background: rgba(255,107,120,0.16); } -/* ============================ Mobile / responsive ============================ */ +/* ─── Mobile ───────────────────────────────────────────────────── */ @media (max-width: 560px) { #ocsb-fab .ocsb-fab-label { display: none; } #ocsb-fab { padding: 13px; right: 16px; bottom: 16px; } - body.ocsb-open #ocsb-panel, - body.ocsb-open.ocsb-expanded #ocsb-panel { - right: 0; bottom: 0; left: 0; top: 0; width: 100vw; height: 100vh; height: 100dvh; - border-radius: 0; grid-template-columns: 0fr 1fr; - } + body.ocsb-open #ocsb-panel, body.ocsb-open.ocsb-expanded #ocsb-panel { inset: 0; width: 100vw; height: 100vh; height: 100dvh; border-radius: 0; grid-template-columns: 0fr 1fr; } body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 0fr 1fr; } - body.ocsb-open.ocsb-rail-open #ocsb-rail { - position: absolute; top: 0; bottom: 0; left: 0; width: 78%; max-width: 290px; z-index: 5; - box-shadow: 24px 0 60px rgba(0, 0, 0, 0.6); - } - .ocsb-bubble { max-width: 88%; } + body.ocsb-open.ocsb-rail-open #ocsb-rail { position: absolute; top: 0; bottom: 0; left: 0; width: 80%; max-width: 290px; z-index: 5; box-shadow: 24px 0 60px rgba(0,0,0,0.6); } } -/* Reduced motion */ +/* ─── Reduced motion ───────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { #ocsb-fab, #ocsb-panel, .ocsb-msg, .ocsb-nav-chip, #ocsb-send, .ocsb-suggestion { transition: none !important; animation: none !important; } - .ocsb-fab-pulse, .ocsb-typing span { animation: none !important; } + .ocsb-fab-pulse, .ocsb-status-dot, .ocsb-typing span { animation: none !important; } #ocsb-messages { scroll-behavior: auto; } } diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js index 88a3e3188e..80e5845869 100644 --- a/assets/js/ocs-bot/index.js +++ b/assets/js/ocs-bot/index.js @@ -316,13 +316,14 @@ function renderActiveConversation() { const id = store.getActiveId(); const convo = id ? store.getConversation(id) : null; const msgsEl = bot.el['ocsb-messages']; - // Remove rendered message nodes (keep the welcome node). msgsEl.querySelectorAll('.ocsb-msg').forEach((n) => n.remove()); bot.el['ocsb-followups'].hidden = true; bot.el['ocsb-followups'].innerHTML = ''; + // Welcome and transcript are mutually-exclusive sibling regions. const hasMsgs = convo && convo.messages.length > 0; bot.el['ocsb-welcome'].hidden = hasMsgs; + bot.el['ocsb-messages'].hidden = !hasMsgs; bot.el['ocsb-status-text'].textContent = 'Ready to help'; if (hasMsgs) { @@ -337,7 +338,7 @@ function renderActiveConversation() { function addUserBubble(text) { const node = document.createElement('div'); node.className = 'ocsb-msg user'; - node.innerHTML = `${USER_AVATAR}
    `; + node.innerHTML = `
    `; node.querySelector('.ocsb-bubble').textContent = text; bot.el['ocsb-messages'].appendChild(node); return node; @@ -346,7 +347,7 @@ function addUserBubble(text) { function addBotBubble(text) { const node = document.createElement('div'); node.className = 'ocsb-msg bot'; - node.innerHTML = `
    `; + node.innerHTML = `
    `; const bubble = node.querySelector('.ocsb-bubble'); const { clean, actions } = parseActions(text); bubble.innerHTML = renderMarkdown(clean); @@ -367,7 +368,7 @@ function appendActionChips(bubble, actions) { chip.innerHTML = `${escapeText(a.label)} ${ARROW}`; wrap.appendChild(chip); }); - bubble.appendChild(wrap); + (bubble.closest('.ocsb-msg-body') || bubble).appendChild(wrap); } // ─── The chat turn ─────────────────────────────────────────────────────────── @@ -384,6 +385,7 @@ async function submit() { ta.value = ''; onInput(); bot.el['ocsb-welcome'].hidden = true; + bot.el['ocsb-messages'].hidden = false; addUserBubble(text); scrollToBottom(); store.appendMessage(convoId, { role: 'user', content: text }); @@ -436,7 +438,7 @@ function finishGenerating() { function addTyping() { const node = document.createElement('div'); node.className = 'ocsb-msg bot'; - node.innerHTML = `
    `; + node.innerHTML = `
    `; bot.el['ocsb-messages'].appendChild(node); return node; } @@ -444,7 +446,7 @@ function addTyping() { function addBotBubbleStreaming() { const node = document.createElement('div'); node.className = 'ocsb-msg bot'; - node.innerHTML = `
    `; + node.innerHTML = `
    `; bot.el['ocsb-messages'].appendChild(node); return node; } From d76216c944919db987f89823fa9cfc23c31988f2 Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 14:58:10 -0700 Subject: [PATCH 05/11] Fix chat clipping when the conversation rail is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the rail used to keep the docked panel at a fixed 384px and steal 184px from the chat column (squishing it to ~200px), so the main content overflowed and clipped on the right. The rail now WIDENS the panel to min(580px, 100vw - 44px) — the chat keeps ~394px — and all panel widths are clamped to the viewport so the panel can never run off-screen. --- assets/css/ocs-bot.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/assets/css/ocs-bot.css b/assets/css/ocs-bot.css index 49086dc1d2..c8350eaffa 100644 --- a/assets/css/ocs-bot.css +++ b/assets/css/ocs-bot.css @@ -70,7 +70,7 @@ body.ocsb-open.ocsb-modal #ocsb-backdrop { opacity: 1; visibility: visible; } /* ─── Panel ────────────────────────────────────────────────────── */ #ocsb-panel { position: fixed; right: 22px; bottom: 22px; z-index: var(--ocsb-z-panel); - width: 384px; height: 600px; max-height: calc(100vh - 48px); + width: min(384px, calc(100vw - 44px)); height: 600px; max-height: calc(100vh - 48px); display: grid; grid-template-columns: 0fr 1fr; background: var(--ocsb-bg-panel); border: 1px solid var(--ocsb-border); border-radius: var(--ocsb-radius-panel); box-shadow: var(--ocsb-shadow-panel); @@ -79,7 +79,10 @@ body.ocsb-open.ocsb-modal #ocsb-backdrop { opacity: 1; visibility: visible; } transition: opacity 200ms ease, transform 240ms cubic-bezier(.22,1,.36,1), visibility 200ms ease, width 260ms ease, height 260ms ease; } body.ocsb-open #ocsb-panel { opacity: 1; visibility: visible; transform: none; } -body.ocsb-open.ocsb-rail-open #ocsb-panel { grid-template-columns: 184px 1fr; } +/* Opening the rail WIDENS the panel (clamped to the viewport) instead of + stealing width from the chat — otherwise the main column is squished and + its content overflows / clips on the right. */ +body.ocsb-open.ocsb-rail-open #ocsb-panel { width: min(580px, calc(100vw - 44px)); grid-template-columns: 184px 1fr; } body.ocsb-open.ocsb-expanded #ocsb-panel { width: min(740px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); } body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: 232px 1fr; } From d1db8ac1f9b7591ce67fc831c3967f24d935c50f Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 15:32:16 -0700 Subject: [PATCH 06/11] Add auto-open, enable/disable toggle, and richer signed-in experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gentle auto-open: the assistant opens itself on a visitor's first visit (rate-limited to once per 7 days, never on phones, respects settings). - User-facing select/deselect: a 'Show the assistant' switch in Settings hides the launcher on this device (persisted); Cmd/Ctrl+K brings it back. Plus an 'Open automatically' toggle. (Per-page disable_ocs_bot opt-out still exists.) - Signed-in users get more: personalized greeting, per-account saved chats (namespaced by uid via /api/id), and — for teachers/admins — staff-only starter prompts and teacher/analytics nav targets. - Re-render starter prompts after sign-in is detected (were stuck on guest set). --- _includes/chatbot/ocs-bot.html | 12 +++++++++++ assets/css/ocs-bot.css | 17 ++++++++++++++++ assets/js/ocs-bot/index.js | 34 ++++++++++++++++++++++++++++++++ assets/js/ocs-bot/knowledge.js | 5 +++++ assets/js/ocs-bot/store.js | 9 ++++++++- assets/js/ocs-bot/suggestions.js | 8 +++++++- 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/_includes/chatbot/ocs-bot.html b/_includes/chatbot/ocs-bot.html index cf500ff629..231784d0b5 100644 --- a/_includes/chatbot/ocs-bot.html +++ b/_includes/chatbot/ocs-bot.html @@ -112,6 +112,18 @@

    Hey! I'm the OCS Assistant 👋

    +
    +

    Assistant

    +
    + Open automaticallyGreet me on my first visit + +
    +
    + Show the assistantTurn the launcher off on this device + +
    + +

    Account

    -
    @@ -148,6 +140,5 @@

    Hey! I'm the OCS Assistant 👋

    Enter to send · Shift+Enter for a new line
    -
    - +`; From 1e79e1c02aec94c1e87d234ad826ecc0dd9a0da5 Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 21:51:30 -0700 Subject: [PATCH 08/11] Keep the chatbot launcher from covering the site's Issue button The 'Ask OCS' launcher is fixed to the bottom-right corner, the same spot as the OCS feedback ('Issue') button (#feedback-btn), so it sat on top of it. The launcher now detects a fixed button sharing that corner and stacks itself just above it (re-checking on resize). On pages without that button it stays in the default corner. --- assets/js/ocs-bot/index.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js index 262f339d61..d35b2a0c14 100644 --- a/assets/js/ocs-bot/index.js +++ b/assets/js/ocs-bot/index.js @@ -79,6 +79,7 @@ function boot() { wireEvents(); applyPrefs(); + positionLauncher(); renderSuggestions(); ensureActiveConversation(); renderRail(); @@ -89,6 +90,9 @@ function boot() { // Gently auto-open on first visit (respects the user's settings). maybeAutoOpen(); + + // Re-check launcher placement after layout/fonts settle. + setTimeout(positionLauncher, 500); } // ─── User / scope ──────────────────────────────────────────────────────── @@ -148,6 +152,10 @@ function wireEvents() { // Delegated clicks: suggestions, nav chips, copy buttons, convo items, followups document.addEventListener('click', delegatedClick); + // Re-stack the launcher above other fixed corner buttons when the page resizes. + let _rzTimer; + window.addEventListener('resize', () => { clearTimeout(_rzTimer); _rzTimer = setTimeout(positionLauncher, 150); }); + // Esc closes; Cmd/Ctrl-K opens document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && document.body.classList.contains('ocsb-open')) { @@ -198,6 +206,28 @@ function delegatedClick(e) { } // ─── Open / close / layout ─────────────────────────────────────────────── +// Keep the launcher from covering other fixed bottom-right buttons (e.g. the +// OCS "Issue" feedback button). If one shares the corner, stack the launcher +// just above it instead of on top of it. +function positionLauncher() { + const fab = bot.el['ocsb-fab']; + if (!fab) return; + let raise = 0; + ['#feedback-btn'].forEach((sel) => { + document.querySelectorAll(sel).forEach((node) => { + if (node === fab || (bot.el['ocsb-panel'] && bot.el['ocsb-panel'].contains(node))) return; + const cs = getComputedStyle(node); + if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden' || !node.offsetHeight) return; + const r = node.getBoundingClientRect(); + // Only count buttons sharing the launcher's bottom-right corner. + if (r.right > window.innerWidth - 240 && r.bottom > window.innerHeight - 180) { + raise = Math.max(raise, window.innerHeight - r.top + 12); + } + }); + }); + fab.style.bottom = raise ? raise + 'px' : ''; +} + function open() { document.body.classList.add('ocsb-open'); if (window.innerWidth <= 560) document.body.classList.add('ocsb-modal'); From aca7070a495b3008c741d8ec478ae57d9e334e4e Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Tue, 2 Jun 2026 22:39:59 -0700 Subject: [PATCH 09/11] Replace welcome tips with a vibrant 'Sign in to save your chats' CTA Removed the 'Real OCS links' and 'Fast Groq AI' tips. Turned the 'Saved when signed in' note into a prominent gradient call-to-action that links to login, placed right under the greeting so it is easy to see, and hidden for users who are already signed in. --- assets/css/ocs-bot.css | 24 ++++++++++++++++++++---- assets/js/ocs-bot/index.js | 5 ++++- assets/js/ocs-bot/widget.js | 18 ++++-------------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/assets/css/ocs-bot.css b/assets/css/ocs-bot.css index 0e2a97d92e..1f77a4bdac 100644 --- a/assets/css/ocs-bot.css +++ b/assets/css/ocs-bot.css @@ -166,9 +166,25 @@ body.ocsb-open.ocsb-expanded.ocsb-rail-open #ocsb-panel { grid-template-columns: .ocsb-suggestion-go { color: var(--ocsb-fg-faint); flex-shrink: 0; display: grid; place-items: center; transition: color 160ms ease, transform 160ms ease; } .ocsb-suggestion-go svg { width: 15px; height: 15px; } .ocsb-suggestion:hover .ocsb-suggestion-go { color: var(--ocsb-brand); transform: translateX(2px); } -.ocsb-tips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 14px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--ocsb-border); width: 100%; } -.ocsb-tip { display: inline-flex; align-items: center; gap: 5px; font-size: 0.7rem; font-weight: 600; color: var(--ocsb-fg-faint); } -.ocsb-tip svg { width: 13px; height: 13px; } +/* Vibrant "sign in to save your chats" call-to-action (guests only) */ +.ocsb-signin-cta { + display: inline-flex; align-items: center; gap: 8px; align-self: center; + margin: 4px 0 16px; padding: 11px 20px; border-radius: 999px; + background: var(--ocsb-bg-fab); color: #fff; font-size: 0.86rem; font-weight: 700; + letter-spacing: -0.01em; text-decoration: none; white-space: nowrap; + box-shadow: 0 8px 22px rgba(20, 80, 200, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.28); + transition: transform 160ms cubic-bezier(.22,1,.36,1), filter 160ms ease, box-shadow 220ms ease; + animation: ocsb-cta-pulse 2.6s ease-in-out infinite; +} +.ocsb-signin-cta:hover { transform: translateY(-2px); filter: brightness(1.07); box-shadow: 0 12px 30px rgba(20, 80, 200, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.32); } +.ocsb-signin-cta:active { transform: translateY(0); } +.ocsb-signin-cta:focus-visible { outline: 3px solid var(--ocsb-brand-cyan, #38e2c2); outline-offset: 2px; } +.ocsb-signin-cta svg { width: 15px; height: 15px; } +.ocsb-signin-cta[hidden] { display: none; } +@keyframes ocsb-cta-pulse { + 0%, 100% { box-shadow: 0 8px 22px rgba(20, 80, 200, 0.45), 0 0 0 0 rgba(76, 175, 239, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.28); } + 50% { box-shadow: 0 10px 26px rgba(20, 80, 200, 0.6), 0 0 0 9px rgba(76, 175, 239, 0), inset 0 1px 0 rgba(255, 255, 255, 0.28); } +} /* ─── Transcript (unified avatar-left thread) ──────────────────── */ #ocsb-messages { padding: 18px 16px; overflow-y: auto; scroll-behavior: smooth; display: flex; flex-direction: column; gap: 14px; } @@ -281,6 +297,6 @@ body.ocsb-disabled #ocsb-fab { display: none !important; } /* ─── Reduced motion ───────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { #ocsb-fab, #ocsb-panel, .ocsb-msg, .ocsb-nav-chip, #ocsb-send, .ocsb-suggestion { transition: none !important; animation: none !important; } - .ocsb-fab-pulse, .ocsb-status-dot, .ocsb-typing span { animation: none !important; } + .ocsb-fab-pulse, .ocsb-status-dot, .ocsb-typing span, .ocsb-signin-cta { animation: none !important; } #ocsb-messages { scroll-behavior: auto; } } diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js index d35b2a0c14..596ca05519 100644 --- a/assets/js/ocs-bot/index.js +++ b/assets/js/ocs-bot/index.js @@ -72,7 +72,7 @@ function boot() { 'ocsb-welcome', 'ocsb-welcome-greeting', 'ocsb-suggestions', 'ocsb-followups', 'ocsb-settings', 'ocsb-set-style', 'ocsb-set-model', 'ocsb-account', 'ocsb-account-text', 'ocsb-clear', 'ocsb-form', 'ocsb-input', 'ocsb-send', - 'ocsb-set-autoopen', 'ocsb-set-enabled', 'ocsb-disabled-note', + 'ocsb-set-autoopen', 'ocsb-set-enabled', 'ocsb-disabled-note', 'ocsb-signin-cta', ].forEach((id) => { el[id] = $(id); }); if (!el['ocsb-fab'] || !el['ocsb-panel']) return; // markup missing — bail safely bot.booted = true; @@ -80,6 +80,7 @@ function boot() { wireEvents(); applyPrefs(); positionLauncher(); + if (bot.el['ocsb-signin-cta']) bot.el['ocsb-signin-cta'].href = hrefFor('/login'); renderSuggestions(); ensureActiveConversation(); renderRail(); @@ -113,6 +114,8 @@ async function detectUser() { } function updateAccountUI() { + // The "sign in to save your chats" CTA only makes sense for guests. + if (bot.el['ocsb-signin-cta']) bot.el['ocsb-signin-cta'].hidden = !!bot.user; const dot = bot.el['ocsb-account'].querySelector('.ocsb-dot2'); if (bot.user) { bot.el['ocsb-account-text'].innerHTML = `Signed in as ${escapeText(bot.user.name)} · chats saved to your account`; diff --git a/assets/js/ocs-bot/widget.js b/assets/js/ocs-bot/widget.js index 03e10cf8fa..99a1230586 100644 --- a/assets/js/ocs-bot/widget.js +++ b/assets/js/ocs-bot/widget.js @@ -67,22 +67,12 @@ export const WIDGET_HTML = `

    Hey! I'm the OCS Assistant 👋

    Ask me about courses, lessons, and projects, or tell me where to go and I'll take you there.

    +

    Try asking

    - From 612fcee40eadd4d1377d1f4c91b2e3337188b352 Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Wed, 3 Jun 2026 12:56:30 -0700 Subject: [PATCH 10/11] Add AP exam practice, confidentiality, and course-awareness to the chatbot (Slack wired but dormant) The assistant now figures out a student's course (CSP, CSA, or CSSE) from their account and gives course-correct practice: real Java FRQs for CSA, exam-style MCQs and Create-PT prompts for CSP, and project help for CSSE (which has no AP exam). It also follows confidentiality rules so it never shares other students' grades, rosters, or secrets, with an output scrubber as a backstop. It can also read the class Slack (#announcements, #general, #coding) and look things up on its own with a [[SLACK: query]] token, but that stays off until the backend and bot token exist (SLACK_ENABLED is false by default), so nothing on the live site changes until an admin turns it on. New modules: collegeboard.js, guardrails.js, identity.js, slack.js. Wired through prompt.js, index.js, api.js, config.js, and suggestions.js. --- assets/js/ocs-bot/api.js | 22 +++++- assets/js/ocs-bot/collegeboard.js | 121 ++++++++++++++++++++++++++++++ assets/js/ocs-bot/config.js | 7 ++ assets/js/ocs-bot/guardrails.js | 34 +++++++++ assets/js/ocs-bot/identity.js | 32 ++++++++ assets/js/ocs-bot/index.js | 53 ++++++++++++- assets/js/ocs-bot/prompt.js | 23 +++++- assets/js/ocs-bot/slack.js | 56 ++++++++++++++ assets/js/ocs-bot/suggestions.js | 1 + 9 files changed, 343 insertions(+), 6 deletions(-) create mode 100644 assets/js/ocs-bot/collegeboard.js create mode 100644 assets/js/ocs-bot/guardrails.js create mode 100644 assets/js/ocs-bot/identity.js create mode 100644 assets/js/ocs-bot/slack.js diff --git a/assets/js/ocs-bot/api.js b/assets/js/ocs-bot/api.js index 633a6616a4..2e19b9eab8 100644 --- a/assets/js/ocs-bot/api.js +++ b/assets/js/ocs-bot/api.js @@ -10,7 +10,7 @@ // ----------------------------------------------------------------------------- import { - GROQ_ENDPOINT, WHOAMI_ENDPOINT, CHAT_API_BASE, + GROQ_ENDPOINT, WHOAMI_ENDPOINT, CHAT_API_BASE, SLACK_ENDPOINT, SLACK_ENABLED, DEFAULT_MODEL, FETCH_TIMEOUT, ENABLE_BACKEND_SYNC, } from './config.js'; @@ -91,6 +91,26 @@ export async function whoAmI() { } } +// ─── Live class Slack context (course-aware, fail-safe) ────────────────────── +// Returns recent relevant posts from the OCS class Slack, or [] on any failure. +// Dormant unless SLACK_ENABLED (the backend + bot token must be deployed first), +// so this is a complete no-op until an OCS admin turns it on. Never throws. +export async function fetchSlack({ query, course, signal } = {}) { + if (!SLACK_ENABLED || !query) return []; + try { + const u = new URL(SLACK_ENDPOINT); + u.searchParams.set('q', query); + u.searchParams.set('limit', '6'); + if (course) u.searchParams.set('course', course); + const r = await safeJson(fetch(u.toString(), { + method: 'GET', credentials: 'include', headers: credHeaders, signal, + })); + return (r.ok && r.data && Array.isArray(r.data.messages)) ? r.data.messages : []; + } catch (_e) { + return []; + } +} + // ─── Optional server-side conversation sync ────────────────────────────────── // All of these resolve to a safe value on any failure (never throw). diff --git a/assets/js/ocs-bot/collegeboard.js b/assets/js/ocs-bot/collegeboard.js new file mode 100644 index 0000000000..96749d00b5 --- /dev/null +++ b/assets/js/ocs-bot/collegeboard.js @@ -0,0 +1,121 @@ +// assets/js/ocs-bot/collegeboard.js +// ----------------------------------------------------------------------------- +// AP exam knowledge so the assistant can generate COURSE-CORRECT practice for the +// student's actual course. Grounded in the current (2026) College Board specs. +// CSP and CSA are AP exams; CSSE is NOT (no AP exam / no FRQ). +// ----------------------------------------------------------------------------- + +export const EXAM = { + csp: { + name: 'AP Computer Science Principles', + isAP: true, + structure: + 'Section I: 70 multiple-choice questions, 120 min, 70% of the score ' + + '(57 single-select, 5 single-select with a reading passage about a computing ' + + 'innovation, 8 multi-select "choose 2"). Section II: the through-course Create ' + + 'Performance Task (a program the student builds) plus an end-of-course Written ' + + 'Response of 2 questions about the student\'s OWN Create program, 60 min with the ' + + 'Personalized Project Reference, 30%.', + note: + 'AP CSP has no classic Java FRQ. The closest to "FRQ" practice is (a) exam-style ' + + 'MCQ drills and (b) Create-PT written-response prompts about the student\'s own program.', + bigIdeas: ['Creative Development', 'Data', 'Algorithms & Programming', + 'Computer Systems & Networks', 'Impact of Computing'], + practiceKinds: { + mcq: 'an exam-style multiple-choice question (4 options) on a Big Idea, in CSP ' + + 'pseudocode or plain language', + written: 'a Create-PT-style written response about the student\'s own program ' + + '(purpose/function, a student-developed procedure with a parameter, the ' + + 'algorithm inside it, and how it was tested)', + reading: 'a single-select question with a short reading passage about a computing ' + + 'innovation and its data/privacy impact', + }, + }, + csa: { + name: 'AP Computer Science A', + isAP: true, + structure: + 'Digital exam (Bluebook). Section I: 40 multiple-choice, 90 min, 55%. ' + + 'Section II: 4 free-response questions in Java, 90 min, 45%, all assessing "Develop Code".', + frqTypes: [ + { id: 1, title: 'Methods and Control Structures', + desc: 'Write 2 methods (or 1 constructor + 1 method) of a given class. Requires ' + + 'iterative and/or conditional statements and calls to methods in the class; ' + + 'one part typically requires String methods.' }, + { id: 2, title: 'Class Design', + desc: 'Design and implement a full class from a written specification and a table ' + + 'of interactions (constructor, instance variables, methods). A second class ' + + 'may be involved.' }, + { id: 3, title: 'Data Analysis with ArrayList', + desc: 'Write 1 method that uses, analyzes, and manipulates data stored in an ArrayList.' }, + { id: 4, title: '2D Array', + desc: 'Write 1 method that uses, analyzes, and manipulates data stored in a 2D array.' }, + ], + rubric: + 'Each FRQ is scored on a points-based rubric by trained readers (historically 9 ' + + 'points; the digital format weights them roughly 5-7 each). Points are awarded for ' + + 'specific code behaviors: correct method header/return type, correct loop/traversal, ' + + 'correct conditional logic, correct use of the required structure ' + + '(String / ArrayList / 2D array), and a correct return value. Style is not graded; ' + + 'correctness and the required structures are.', + units: ['Primitive Types', 'Using Objects', 'Boolean Expressions & if', 'Iteration', + 'Writing Classes', 'Array', 'ArrayList', '2D Array', 'Inheritance', 'Recursion'], + }, + csse: { + name: 'Computer Science Software Engineering (CSSE)', + isAP: false, + note: + 'CSSE is NOT an AP College Board exam course. There is no AP exam and no FRQ. For ' + + 'CSSE, help with the project/quest work, software-engineering practices (design, ' + + 'testing, deployment, code review), and the gamify / game-dev tracks instead.', + }, +}; + +export function courseExam(course) { return EXAM[(course || '').toLowerCase()] || null; } +export function isAPCourse(course) { const e = courseExam(course); return !!(e && e.isAP); } + +const PRACTICE_RE = new RegExp( + '\\b(practice|frq|free[- ]?response|mcq|multiple[- ]?choice|quiz(\\s*me)?|drill|mock|' + + 'sample (question|problem|exam)|test me|study (for|question)|exam (question|practice)|' + + 'give me (a|an|some)\\b.*\\b(question|problem|frq))\\b', 'i'); + +export function looksLikePractice(text) { return PRACTICE_RE.test(text || ''); } + +// System-prompt block teaching the model how to generate course-correct practice for +// THIS student's course. Empty string if course unknown. +export function practicePrompt(course) { + const e = courseExam(course); + if (!e) return ''; + if (!e.isAP) { + return 'PRACTICE/EXAM CONTEXT: ' + e.note + ' If the student asks for AP practice or ' + + 'an FRQ, gently explain CSSE has no AP exam and offer software-engineering / ' + + 'project practice instead.'; + } + if (e === EXAM.csa) { + const types = e.frqTypes.map((t) => `Q${t.id} ${t.title}: ${t.desc}`).join('\n '); + return [ + 'PRACTICE MODE — ' + e.name + ' (the student\'s course).', + 'Exam: ' + e.structure, + 'The 4 FRQ types are:\n ' + types, + 'Scoring: ' + e.rubric, + 'When the student asks for a practice FRQ: use the type they request (or pick one), ' + + 'and write a realistic Java FRQ in the official style — a short scenario, any needed ' + + 'class skeleton in a ```java code block, and a clear "Write the method ..." ' + + 'instruction with the exact signature. Do NOT reveal the solution yet. Offer to grade ' + + 'their attempt. When they submit code, score it point-by-point against a rubric, then ' + + 'show a model solution.', + ].join('\n'); + } + // CSP + const kinds = Object.entries(e.practiceKinds).map(([k, v]) => `- ${k}: ${v}`).join('\n'); + return [ + 'PRACTICE MODE — ' + e.name + ' (the student\'s course).', + 'Exam: ' + e.structure, + e.note, + 'Big Ideas: ' + e.bigIdeas.join(', ') + '.', + 'Practice you can generate:\n' + kinds, + 'Default to one exam-style MCQ unless they ask for written-response or Create-PT ' + + 'practice. Reveal the answer and a one-line explanation only AFTER the student answers, ' + + 'and tie it to the relevant Big Idea.', + ].join('\n'); +} diff --git a/assets/js/ocs-bot/config.js b/assets/js/ocs-bot/config.js index 092d505556..674aa0f340 100644 --- a/assets/js/ocs-bot/config.js +++ b/assets/js/ocs-bot/config.js @@ -31,6 +31,13 @@ export const WHOAMI_ENDPOINT = `${API_BASE}/api/id`; // Optional server-side chat history (graceful: silently unused if not deployed). export const CHAT_API_BASE = `${API_BASE}/api/chat`; +// Live class-Slack context (course-aware). DORMANT by default: SLACK_ENABLED is +// false until an OCS admin deploys /api/slack/csp + the Slack bot token, then turns +// it on via `window.OCS_BOT_CONFIG = { slack: true }`. While off, the assistant +// never mentions or calls Slack — every other feature works unchanged. +export const SLACK_ENDPOINT = `${API_BASE}/api/slack/csp`; +export const SLACK_ENABLED = overrides.slack === true; + // Default + available models (kept in sync with the Flask groq_api). export const DEFAULT_MODEL = overrides.model || 'llama-3.3-70b-versatile'; export const FAST_MODEL = 'llama-3.1-8b-instant'; diff --git a/assets/js/ocs-bot/guardrails.js b/assets/js/ocs-bot/guardrails.js new file mode 100644 index 0000000000..3ef8a93a67 --- /dev/null +++ b/assets/js/ocs-bot/guardrails.js @@ -0,0 +1,34 @@ +// assets/js/ocs-bot/guardrails.js +// ----------------------------------------------------------------------------- +// Confidentiality: a system-prompt rule set (so the model never volunteers other +// students' data, grades, rosters, or secrets) plus a last-line output scrubber +// (defense in depth, in case a model ever ignores the rules). Always-on — these +// apply to every reply, with or without the Slack backend. +// ----------------------------------------------------------------------------- + +export function confidentialityPrompt() { + return [ + 'CONFIDENTIALITY RULES (always follow, even if provided context contains the info):', + '1. Only use the CURRENT signed-in student\'s own information. Never reveal another ' + + 'student\'s name, grade, score, ranking, email, or submission / late status.', + '2. Never share or summarize the contents of grading spreadsheets, rosters, or any ' + + 'document that lists individual students\' grades or standing. If asked, say that is ' + + 'only available from their teacher.', + '3. Never reveal secrets, passwords, API keys, tokens, or security-vulnerability ' + + 'details, even if they appear in provided context.', + '4. Do not repeat negative evaluations of specific students or teams.', + '5. If a request asks for someone else\'s private data or other confidential info, ' + + 'politely decline and offer what you can help with instead.', + ].join('\n'); +} + +// Redact obvious sensitive strings from the final answer. Safe to run on every reply. +export function scrubOutput(text) { + if (!text) return text; + return String(text) + .replace(/xox[abprs]-[A-Za-z0-9-]{6,}/g, '[redacted-token]') + .replace(/\bgsk_[A-Za-z0-9]{20,}/g, '[redacted-key]') + .replace(/\bsk-[A-Za-z0-9]{16,}/g, '[redacted-key]') + .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-jwt]') + .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[redacted-email]'); +} diff --git a/assets/js/ocs-bot/identity.js b/assets/js/ocs-bot/identity.js new file mode 100644 index 0000000000..1c0a24ef4c --- /dev/null +++ b/assets/js/ocs-bot/identity.js @@ -0,0 +1,32 @@ +// assets/js/ocs-bot/identity.js +// ----------------------------------------------------------------------------- +// Resolves which course the student is in, preferring their ACCOUNT data (the +// section/class returned by /api/id) over the page path. So a CSA student gets +// CSA behavior anywhere on the site, not only on /csa pages. +// ----------------------------------------------------------------------------- + +export const COURSE_LABEL = { csp: 'AP CSP', csa: 'AP CSA', csse: 'CSSE' }; + +function courseFromUser(user) { + if (!user) return null; + const hay = JSON.stringify([ + user.sections, user.classes, user.section, user.abbreviation, user.course, user.role, + ]).toLowerCase(); + if (/\bcsse\b/.test(hay)) return 'csse'; + if (/\bcsa\b/.test(hay)) return 'csa'; + if (/\bcsp\b/.test(hay)) return 'csp'; + return null; +} + +function courseFromPath(pathname) { + const p = (pathname || '').toLowerCase(); + if (p.indexOf('/csse') !== -1) return 'csse'; + if (p.indexOf('/csa') !== -1) return 'csa'; + if (p.indexOf('/csp') !== -1) return 'csp'; + return null; +} + +// Account data first, page path second. +export function resolveCourse(user, pathname) { + return courseFromUser(user) || courseFromPath(pathname) || null; +} diff --git a/assets/js/ocs-bot/index.js b/assets/js/ocs-bot/index.js index 596ca05519..f7355147b1 100644 --- a/assets/js/ocs-bot/index.js +++ b/assets/js/ocs-bot/index.js @@ -12,7 +12,11 @@ import { renderMarkdown } from './render.js'; import { parseActions, hrefFor } from './tools.js'; import { starterSuggestions } from './suggestions.js'; import { NAV_INDEX } from './knowledge.js'; -import { DEFAULT_MODEL, NAV_TARGET } from './config.js'; +import { resolveCourse } from './identity.js'; +import { looksLikePractice } from './collegeboard.js'; +import { looksClassRelated, formatSlackContext, SLACK_REQUEST_RE } from './slack.js'; +import { scrubOutput } from './guardrails.js'; +import { DEFAULT_MODEL, NAV_TARGET, SLACK_ENABLED } from './config.js'; import { WIDGET_HTML } from './widget.js'; const $ = (id) => document.getElementById(id); @@ -47,6 +51,7 @@ const ICONS = { const bot = { el: {}, user: null, + course: null, generating: false, abort: null, booted: false, @@ -100,6 +105,7 @@ function boot() { async function detectUser() { const user = await api.whoAmI(); bot.user = user; + bot.course = resolveCourse(user, location.pathname); // course from account, page fallback // Re-scope storage to this user and re-render (migrating the current view). store.setScope(user ? user.id : 'guest'); ensureActiveConversation(); @@ -480,12 +486,42 @@ async function submit() { const prefs = store.getPrefs(); const convo = store.getConversation(convoId); const history = convo.messages.slice(-HISTORY_TURNS).map((m) => ({ role: m.role, content: m.content })); - const messages = [{ role: 'system', content: buildSystemPrompt({ user: bot.user, prefs }) }, ...history]; + const model = prefs.model || DEFAULT_MODEL; + const course = bot.course || resolveCourse(bot.user, location.pathname); + const practice = looksLikePractice(text); bot.abort = new AbortController(); + + // Pre-fetch class Slack for obvious class questions (no-op unless SLACK_ENABLED + // and the backend is deployed). The model can also self-request via [[SLACK:]]. + let slackCtx = []; + if (SLACK_ENABLED && !practice && looksClassRelated(text)) { + bot.el['ocsb-status-text'].textContent = 'Checking class Slack…'; + slackCtx = await api.fetchSlack({ query: text, course, signal: bot.abort.signal }); + } + const sysContent = () => buildSystemPrompt({ user: bot.user, prefs, course, slack: slackCtx, practice, slackEnabled: SLACK_ENABLED }); + let answer = ''; try { - answer = await api.chat({ messages, model: prefs.model || DEFAULT_MODEL, signal: bot.abort.signal }); + answer = await api.chat({ messages: [{ role: 'system', content: sysContent() }, ...history], model, signal: bot.abort.signal }); + + // The model can ask to look something up itself via a [[SLACK: query]] line. + if (SLACK_ENABLED && !practice) { + const m = answer.match(SLACK_REQUEST_RE); + if (m) { + bot.el['ocsb-status-text'].textContent = 'Searching class Slack…'; + const more = await api.fetchSlack({ query: m[1].trim(), course, signal: bot.abort.signal }); + slackCtx = dedupeSlack(slackCtx.concat(more)); + answer = await api.chat({ + messages: [ + { role: 'system', content: sysContent() }, ...history, + { role: 'assistant', content: answer }, + { role: 'user', content: 'Matching class Slack posts:\n' + (formatSlackContext(more) || '(none found)') + '\n\nNow answer my question directly and cite the channel. Do not output another [[SLACK:]] request.' }, + ], + model, signal: bot.abort.signal, + }); + } + } } catch (err) { typingNode.remove(); finishGenerating(); @@ -493,6 +529,9 @@ async function submit() { return; } + // Confidentiality safety net + strip any leftover retrieval token before display. + answer = scrubOutput(answer.replace(/\[\[SLACK:[^\]\n]*\]\]/gi, '').trim()); + // 4) render with typewriter, then persist typingNode.remove(); const node = addBotBubbleStreaming(); @@ -632,6 +671,14 @@ function showError(msg) { bot.el['ocsb-error'].hidden = false; } function hideError() { bot.el['ocsb-error'].hidden = true; } +function dedupeSlack(list) { + const seen = new Set(), out = []; + for (const m of (list || [])) { + const k = (m.channel || '') + (m.text || ''); + if (!seen.has(k)) { seen.add(k); out.push(m); } + } + return out; +} function escapeText(s) { const d = document.createElement('div'); d.textContent = String(s == null ? '' : s); diff --git a/assets/js/ocs-bot/prompt.js b/assets/js/ocs-bot/prompt.js index 49dc77870a..3292915565 100644 --- a/assets/js/ocs-bot/prompt.js +++ b/assets/js/ocs-bot/prompt.js @@ -8,6 +8,10 @@ // ----------------------------------------------------------------------------- import { SITE, NAV_INDEX, COURSE_FACTS } from './knowledge.js'; +import { confidentialityPrompt } from './guardrails.js'; +import { capabilityPrompt, formatSlackContext } from './slack.js'; +import { practicePrompt } from './collegeboard.js'; +import { COURSE_LABEL } from './identity.js'; function directory() { const byGroup = {}; @@ -26,13 +30,28 @@ const STYLE = { detailed: 'Give a thorough answer with steps, context, and examples when useful. Use headings/lists for structure.', }; -export function buildSystemPrompt({ user, prefs } = {}) { +export function buildSystemPrompt({ user, prefs, course, slack, practice, slackEnabled } = {}) { const style = STYLE[prefs?.style] || STYLE.balanced; const userBlock = user ? `\nSIGNED-IN USER: ${user.name}${user.role ? ` (role: ${user.role})` : ''}. You may greet them by first name ("${user.firstName}") when natural. ${user.isTeacher || user.isAdmin ? 'This user is a teacher/admin — you can mention teacher tools and the dashboard.' : 'This is a student.'}` : `\nThe user is NOT signed in. If they want to save chats across sessions/devices, track progress, or submit work, suggest they sign in (navigate them to /login) — but only when relevant, never naggingly.`; + // Always-on: confidentiality + (when known) the student's course. Then either + // exam-practice mode, or the live-Slack capability (only when SLACK_ENABLED). + const courseLine = course + ? `\n\nThe signed-in student is enrolled in ${COURSE_LABEL[course] || course.toUpperCase()}. When a question is course-specific, prefer that course.` + : ''; + let featureBlocks = `\n\n${confidentialityPrompt()}${courseLine}`; + if (practice && course) { + featureBlocks += `\n\n${practicePrompt(course)}`; + } else if (slackEnabled) { + featureBlocks += `\n\n${capabilityPrompt()}`; + if (slack && slack.length) { + featureBlocks += `\n\n${formatSlackContext(slack)}\n\nUse that context to answer and cite the channel (e.g. "per #announcements"). If it does not contain the answer, you may request more with [[SLACK: query]].`; + } + } + return `You are the **OCS Assistant**, the friendly built-in helper on ${SITE.name}'s website (${SITE.url}). You appear on every page of the site. WHO OCS IS: @@ -60,5 +79,5 @@ STYLE: ${style} - Format with Markdown (short paragraphs, **bold**, bullet lists, and \`code\`/code blocks for code). - If you're unsure or the answer isn't about OCS, say so briefly and point to /search/ or /navigation/blogs/. - Never fabricate lesson names, deadlines, or grades. Stick to what's in this prompt; for specifics you don't know, suggest where on the site to look. -${userBlock}`; +${userBlock}${featureBlocks}`; } diff --git a/assets/js/ocs-bot/slack.js b/assets/js/ocs-bot/slack.js new file mode 100644 index 0000000000..109877636d --- /dev/null +++ b/assets/js/ocs-bot/slack.js @@ -0,0 +1,56 @@ +// assets/js/ocs-bot/slack.js +// ----------------------------------------------------------------------------- +// Live class-Slack helpers for the assistant: the class-question heuristic, the +// capability text (so the model KNOWS it can read Slack and can self-trigger a +// lookup via a [[SLACK: query]] token — same idea as the [[GO:/path]] nav token), +// and context formatting. The network call lives in api.js (fetchSlack); course +// resolution in identity.js. Reads the shared #announcements / #general / #coding +// channels, which cover all periods of CSP, CSA and CSSE. +// +// All of this is DORMANT unless config.SLACK_ENABLED is true (the OCS Slack backend +// + bot token must be deployed first). Until then the bot never mentions Slack. +// ----------------------------------------------------------------------------- + +const CLASS_RE = new RegExp( + '\\b(' + [ + 'homework', 'hw', 'assignment', 'assignments', 'due', 'deadline', 'late', + 'grade', 'grading', 'graded', 'rubric', + 'project', 'projects', 'capstone', 'sprint', 'sprint9', 'milestone', + 'n@tm', 'natm', 'night at the museum', 'expo', 'cte', 'presentation', + 'panel', 'panelist', 'speaker', 'demo', 'demos', + 'ap test', 'ap exam', 'requirements\\.txt', 'no module', 'modulenotfound', + 'wsl', 'ubuntu', 'deploy', 'deployment', 'blog', 'reflection', 'issue', + 'mortensen', 'mr ?mort', 'announcement', 'announced', 'sign ?off', + 'repo', 'readme', 'this week', 'what.?s due', 'when is', + 'csp', 'csa', 'csse', 'quest', 'gamify', 'lxd', 'clicker', + 'fork', 'forking', 'baseurl', '_config', + ].join('|') + ')\\b', 'i' +); + +export function looksClassRelated(text) { return CLASS_RE.test(text || ''); } + +export function formatSlackContext(msgs) { + if (!msgs || !msgs.length) return ''; + const lines = msgs.map((m) => { + const links = (m.links && m.links.length) ? ' Links: ' + m.links.join(' ') : ''; + return '- [' + m.channel + ' | ' + m.author + ' | ' + m.date + '] ' + m.text + links; + }); + return 'LIVE CLASS SLACK CONTEXT (most recent posts across CSP, CSA and CSSE, ' + + 'read-only, from the OCS Slack):\n' + lines.join('\n'); +} + +// The model can request its own lookup by emitting one of these lines. +export const SLACK_REQUEST_RE = /\[\[SLACK:\s*([^\]\n]+)\]\]/i; + +export function capabilityPrompt() { + return [ + 'You can read the school\'s live class Slack: recent posts from #announcements,', + '#general and #coding, which cover all three courses (CSP, CSA, CSSE), including', + 'homework, deadlines, grading days, quests, events, build fixes, and Mr.', + 'Mortensen\'s announcements. If a student asks whether you can see Slack or check', + 'announcements, say yes and offer to look. When you need class info that was not', + 'already provided to you, request it by outputting a single line exactly like', + '[[SLACK: short search query]] and nothing else. The system will reply with the', + 'matching posts, and you then answer, citing the channel.', + ].join(' '); +} diff --git a/assets/js/ocs-bot/suggestions.js b/assets/js/ocs-bot/suggestions.js index 3aa0a3949b..c917091dad 100644 --- a/assets/js/ocs-bot/suggestions.js +++ b/assets/js/ocs-bot/suggestions.js @@ -17,6 +17,7 @@ const POOL = [ { icon: 'calendar', text: "Where's the course calendar?" }, { icon: 'rocket', text: "What is 'Night at the Museum'?" }, { icon: 'cap', text: 'Help me prep for the AP CSA exam' }, + { icon: 'spark', text: 'Give me a practice FRQ for my course' }, { icon: 'terminal', text: 'Open the Linux CTF challenges' }, ]; From 2217bba5dca75f22f5fa0e4640ac4aab06fe0696 Mon Sep 17 00:00:00 2001 From: Samarth Vaka Date: Thu, 4 Jun 2026 11:21:18 -0700 Subject: [PATCH 11/11] Add AI search hub (learning paths + surveys) and make the chatbot load per-page The search page now has a bright "Ask AI" button that opens the OCS Learning Guide. Instead of just listing pages like the normal search, it builds a step by step learning path through real site pages (basics to advanced), and for beginners it runs a quick clickable survey to figure out their level and goal first. It reuses the chatbot's Groq endpoint and page index and adds two tools the model can emit: [[ASK:]] for a survey and [[STEP:]] for a path step (both validated against the real page index so a step can never link to a fake URL). Also made the floating chatbot conditional per page instead of force-loaded on every page: it loads when a page sets ocs_bot: true, or it inherits the site default from _config.yml (currently true). The search page opts out with ocs_bot: false since it has the dedicated hub. New: assets/js/ocs-search/{protocol,searchprompt,searchhub}.js and assets/css/ocs-search.css. Also touches custom-head.html, _config.yml, navigation/search.md, and the search layout. --- _config.yml | 6 +- _includes/custom-head.html | 17 ++- _layouts/search.html | 5 + assets/css/ocs-search.css | 184 ++++++++++++++++++++++ assets/js/ocs-search/protocol.js | 57 +++++++ assets/js/ocs-search/searchhub.js | 220 +++++++++++++++++++++++++++ assets/js/ocs-search/searchprompt.js | 50 ++++++ navigation/search.md | 2 + 8 files changed, 535 insertions(+), 6 deletions(-) create mode 100644 assets/css/ocs-search.css create mode 100644 assets/js/ocs-search/protocol.js create mode 100644 assets/js/ocs-search/searchhub.js create mode 100644 assets/js/ocs-search/searchprompt.js diff --git a/_config.yml b/_config.yml index 680b6c3f74..aaf2d5d0b4 100644 --- a/_config.yml +++ b/_config.yml @@ -20,9 +20,13 @@ description: "Class of 2026" owner_name: Open Coding Society github_username: open-coding-society github_repo: "pages" -baseurl: "" +baseurl: "" future: true +# OCS Assistant: load the floating chatbot site-wide by default. Any page can +# override per-page with `ocs_bot: false` (or `true`) in its front matter. +ocs_bot: true + # Exclude from Jekyll watch - these are processed by our conversion scripts # This prevents double-regeneration when saving notebooks/docx files exclude: diff --git a/_includes/custom-head.html b/_includes/custom-head.html index 3a20a6b2b9..eea8160b8d 100644 --- a/_includes/custom-head.html +++ b/_includes/custom-head.html @@ -3,12 +3,19 @@ {% include head-custom.html %} -{%- unless page.disable_ocs_bot -%} - +{%- comment -%} + OCS Assistant — loaded CONDITIONALLY per page (not force-embedded everywhere). + A page loads the floating assistant when `ocs_bot: true` is in its front matter, + or it inherits the site-wide default `ocs_bot` from _config.yml. Turn it off on a + single page with `ocs_bot: false`, or flip the whole site from _config.yml. The + legacy `disable_ocs_bot: true` opt-out is still honored. +{%- endcomment -%} +{%- assign ocs_bot_on = site.ocs_bot -%} +{%- unless page.ocs_bot == nil -%}{%- assign ocs_bot_on = page.ocs_bot -%}{%- endunless -%} +{%- if page.disable_ocs_bot -%}{%- assign ocs_bot_on = false -%}{%- endif -%} +{%- if ocs_bot_on -%} -{%- endunless -%} +{%- endif -%} \ No newline at end of file diff --git a/_layouts/search.html b/_layouts/search.html index ec3c099d0d..b0c8f6121e 100644 --- a/_layouts/search.html +++ b/_layouts/search.html @@ -6,6 +6,11 @@ + + + + +