diff --git a/README.md b/README.md index 1ef67521c9..935d54a54a 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,23 @@ Hit `CTRL+C` in the terminal that is showing `hugo` activity to interrupt the se Style overrides are in `/assets/scss/_variables_project.scss`. Here we can override all the styles that ship with the Docsy theme. O +## Using the Chat Widget + +To use the chat widget (`static/chat-widget.js` and `static/chat-widget.css`): + +- Ensure both files are included in your HTML page. +- The widget communicates with a backend service. By default, it connects to `http://localhost:8018/docs-agent`. + - To use a different backend URL or port, set `window.DOCS_AGENT_BACKEND_URL` before loading the widget script: + + ```html + + + + ``` +- The chat widget will use this URL for all requests. + ## Troubleshooting As you run the website locally, you may run into the following error: diff --git a/hugo.yaml b/hugo.yaml index f98f84e7ed..6dfe6d9e3c 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -79,6 +79,7 @@ services: id: GTM-5BL5RTH params: + docs_agent_backend_url: "http://localhost:8018/docs-agent" #search: # algolia: # appId: 2D210VB5MP diff --git a/layouts/partials/footer.html b/layouts/partials/footer.html new file mode 100644 index 0000000000..ce5116c584 --- /dev/null +++ b/layouts/partials/footer.html @@ -0,0 +1,41 @@ +{{/* layouts/partials/footer.html */}} +{{/* This overrides the theme's footer partial */}} + + + + + + \ No newline at end of file diff --git a/static/chat-widget.css b/static/chat-widget.css new file mode 100644 index 0000000000..1fdb6e36f6 --- /dev/null +++ b/static/chat-widget.css @@ -0,0 +1,745 @@ +/* +Weights & Biases Official Brand Colors::after + +Primary Colors: +Name: Moon-900, Hex: 1A1D24, RGB: 23, 26, 31 +Name: Gold-450, Hex: FAC13C, RGB: 250, 193, 60 +Name: Moon-50, Hex: FAFAFA, RGB: 250, 250, 250 + +Secondary Colors: +Name: Teal-450, Hex:10BFCC, RGB: 16, 191, 204 +Name: Moon-450, Hex: 8E949E, RGB: 142, 191, 204 +*/ + +/* ChatUI-inspired Beautiful Chat Widget Styles (MIT License) */ +#chat-widget-btn { + position: fixed; bottom: 25px; right: 28px; + background: #FAC13C; border: none; border-radius: 50%; width: 60px; height: 60px; + box-shadow: 0 4px 24px rgba(23,26,31,0.18); + display: flex; align-items: center; justify-content: center; + cursor: pointer; z-index: 99999; + pointer-events: auto; + transition: background 0.2s, box-shadow 0.2s; + outline: none; +} +#chat-widget-btn:hover, #chat-widget-btn:focus { + background: #10BFCC; + box-shadow: 0 6px 32px rgba(23,26,31,0.25); +} +#chat-widget-window { + position: fixed; bottom: 85px; right: 30px; width: 800px; max-width: 99vw; + height: 600px; max-height: 90vh; z-index: 9999; + background: #1A1D24; + color: #FAFAFA; + border-radius: 18px 18px 10px 10px; + box-shadow: 0 8px 32px rgba(26,29,36,0.32); + display: flex; flex-direction: column; + overflow: hidden; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; + font-size: 0.98em; + border: 1px solid #8E949E; +} +#chat-widget-header { + background: #23261F; color: #FAC13C; + padding: 12px 18px; border-radius: 18px 18px 0 0; + display: flex; align-items: center; justify-content: space-between; + font-weight: 600; font-size: 1em; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; +} +.chat-widget-gradient-bar { + height: 4px; + width: 100%; + background: linear-gradient(90deg, #FAC13C 0%, #10BFCC 20%, #8E949E 40%, #FAC13C 60%, #10BFCC 80%, #FAC13C 100%); + background-size: 400% 100%; + animation: chat-widget-gradient-move 60s linear infinite; + margin: 0; + border: none; + border-radius: 0 0 8px 8px; + transition: animation-duration 0.2s; +} +.chat-widget-gradient-fast { + animation-duration: 1.2s !important; +} +@keyframes chat-widget-gradient-move { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } +} +#chat-widget-messages { + flex: 1; overflow-y: auto; padding: 18px 6px 0 18px; + background: transparent; +} +#chat-widget-close { + background: transparent; border: none; color: #1A1D24; font-size: 1.5em; cursor: pointer; + margin-left: 10px; line-height: 1; +} +#chat-widget-close-bottom { + margin-left: 10px; + background: transparent; + border: none; + color: #FAFAFA; + font-size: 1.4em; + cursor: pointer; + border-radius: 8px; + padding: 0 10px; + transition: background 0.18s, color 0.18s; + align-self: center; +} +#chat-widget-window.chat-widget-light #chat-widget-close-bottom { + color: #1A1D24; +} +#chat-widget-close-bottom:hover, #chat-widget-close-bottom:focus { + background: #FAC13C; + color: #1A1D24; +} +#chat-widget-form { + display: flex; border-top: 1px solid #8E949E; background: #1A1D24; padding: 10px 12px; +} +#chat-widget-window.chat-widget-light #chat-widget-form { + background: #8E949E !important; /* Moon-450 */ + border-top: 1px solid #D9DCE1 !important; +} +#chat-widget-window.chat-widget-light #chat-widget-input { + background: #FFFFFF !important; + color: #1A1D24 !important; + border: 1px solid #10BFCC !important; +} +#chat-widget-input { + flex: 1; border: 1px solid #10BFCC; border-radius: 10px; padding: 8px 12px; + font-size: 0.97em; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; + outline: none; transition: border 0.2s, box-shadow 0.2s; + background: #23261F; color: #FAFAFA; + resize: none; + min-height: 36px; + max-height: 120px; + line-height: 1.4; + box-sizing: border-box; + font-family: inherit; + overflow-y: auto; + margin-right: 8px; +} +#chat-widget-input:focus { border: 1.5px solid #FAC13C; } +#chat-widget-input.chat-widget-input-overflow { + border: 1.5px solid #FF4D4F; + box-shadow: 0 0 0 2px rgba(255,77,79,0.15); +} +@keyframes shake { + 0% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } + 100% { transform: translateX(0); } +} +#chat-widget-input.shake { + animation: shake 0.4s; +} +#chat-widget-form button { + margin-left: 10px; background: #10BFCC; color: #1A1D24; border: none; border-radius: 10px; + padding: 0 18px; font-size: 1em; cursor: pointer; display: flex; align-items: center; + transition: background 0.2s; +} +#chat-widget-form button:hover, #chat-widget-form button:focus { background: #FAC13C; color: #1A1D24; } +#chat-widget-form button[disabled], #chat-widget-form button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +#chat-widget-form .chat-widget-send-icon path { + transition: fill 0.2s; +} +#chat-widget-window.chat-widget-light #chat-widget-form .chat-widget-send-icon path { + fill: #1A1D24; +} +#chat-widget-window.chat-widget-dark #chat-widget-form .chat-widget-send-icon path { + fill: #FAFAFA; +} +#chat-widget-window.chat-widget-light #chat-widget-form button:hover .chat-widget-send-icon path, +#chat-widget-window.chat-widget-light #chat-widget-form button:focus .chat-widget-send-icon path, +#chat-widget-window.chat-widget-dark #chat-widget-form button:hover .chat-widget-send-icon path, +#chat-widget-window.chat-widget-dark #chat-widget-form button:focus .chat-widget-send-icon path { + fill: #10BFCC; +} + +/* Chat bubble, avatar, and message row styles */ +.chat-widget-msg { + display: flex; align-items: flex-end; margin-bottom: 14px; +} +.chat-widget-avatar { + display: none; +} +.chat-widget-bubble { + box-shadow: none; + background: none; + color: inherit; + padding: 0; + margin: 0; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; + font-size: 0.97em !important; +} +.chat-widget-user-bubble { + display: flex; + align-items: center; + gap: 1px; + background: #10BFCC; + color: #FAFAFA; + border-radius: 18px; + box-shadow: 0 2px 8px rgba(23,26,31,0.07); + padding: 8px 16px 4px 2px; + margin: 4px 0 4px 0; + max-width: 80%; + min-width: 48px; + justify-content: flex-start; +} +.chat-widget-avatar-inside { + display: flex; + align-items: flex-start; + justify-content: center; + width: 28px; + height: 28px; + margin: -5px 2px 0 0; + background: none; + border-radius: 50%; + overflow: visible; + box-shadow: none; + padding: 0; +} +.chat-widget-user-text { + display: block; + word-break: break-word; + font-size: 0.97em !important; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; + padding: 0; + margin: 0; +} +.chat-widget-ai-content-with-avatar { + display: block; + width: 100%; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; + font-size: 0.97em !important; +} +.chat-widget-ai-content { + width: 100%; + padding: 0 0 1px 2px; /* Adjust left padding to 2px for fine alignment */ + background: none; + color: #FAFAFA; + font-size: 0.97em !important; + font-family: 'Source Sans Pro', 'Arial', 'Helvetica Neue', Arial, sans-serif !important; + word-break: break-word; + display: block; + margin-bottom: 12px; +} +.chat-widget-ai-avatar-row { + margin-top: 4px; + margin-bottom: 4px; + display: block; + width: 100%; + height: 36px; + padding-left: 1px; /* Move AI avatar icon right by 1px */ + text-align: left !important; +} +.chat-widget-ai-avatar-row svg { + width: 36px; height: 36px; + display: block; + margin: 0; +} +.chat-widget-loading { + color: #8E949E; font-style: italic; +} +.chat-widget-error { + color: #c62828; font-style: italic; +} + +/* Support chat bubble for support agent */ +.chat-widget-support-bubble { + background: linear-gradient(90deg, #FFD6F9 0%, #F6E6FF 100%); + color: #A02C7D; + border-radius: 12px; + padding: 18px 20px 18px 20px; + margin: 12px 0 12px 54px; + box-shadow: 0 2px 14px 0 rgba(160,44,125,0.07); + font-size: 0.96em; + max-width: 80%; + position: relative; + border: 1.5px solid #F2C3F1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 64px; +} +.chat-widget-support-label { + font-size: 0.88em; + font-weight: 700; + color: #C73DD6; + margin-bottom: 8px; + letter-spacing: 0.02em; +} +.chat-widget-support-text { + font-size: 0.96em; + font-weight: 400; + color: #A02C7D; + margin: 0 0 6px 0; +} +.chat-widget-user-text-left { + margin-left: 2px; + padding-left: 0; + display: inline-block; +} + +/* Markdown headings and block elements in AI content */ +.chat-widget-ai-content h1, +.chat-widget-ai-content h2, +.chat-widget-ai-content h3, +.chat-widget-ai-content h4, +.chat-widget-ai-content h5, +.chat-widget-ai-content h6 { + font-weight: 700; + margin: 1.1em 0 0.6em 0; + color: #FAC13C; + line-height: 1.2; +} +.chat-widget-ai-content h1 { font-size: 2em; } +.chat-widget-ai-content h2 { font-size: 1.6em; } +.chat-widget-ai-content h3 { font-size: 1.3em; } +.chat-widget-ai-content h4 { font-size: 1.1em; } +.chat-widget-ai-content h5 { font-size: 1em; } +.chat-widget-ai-content h6 { font-size: 0.95em; } +.chat-widget-ai-content ul, +.chat-widget-ai-content ol { + margin: 0.7em 0 0.7em 2em; + padding: 0 0 0 1.2em; +} +.chat-widget-ai-content pre { + background: #23261F; + color: #FAFAFA; + border-radius: 8px; + padding: 12px 16px; + overflow-x: auto; + margin: 1em 0; +} + +/* Theme sync: override window, header, input based on .chat-widget-dark/.chat-widget-light */ +#chat-widget-window.chat-widget-dark { + background: #1A1D24 !important; + color: #FAFAFA !important; + box-shadow: 0 8px 32px rgba(26,29,36,0.32); +} +#chat-widget-window.chat-widget-dark #chat-widget-header { + background: #23261F !important; + color: #FAC13C !important; +} +#chat-widget-window.chat-widget-dark #chat-widget-input { + background: #23261F !important; + color: #FAFAFA !important; + border: 1px solid #10BFCC !important; +} +#chat-widget-window.chat-widget-light { + background: #FAFAFA !important; + color: #1A1D24 !important; + box-shadow: 0 8px 32px rgba(26,29,36,0.10); + border: 1px solid #D9DCE1 !important; +} +#chat-widget-window.chat-widget-light #chat-widget-header { + background: #F5F5F5 !important; + color: #FAC13C !important; +} +#chat-widget-window.chat-widget-light #chat-widget-input { + background: #F5F5F5 !important; + color: #1A1D24 !important; + border: 1px solid #10BFCC !important; +} + +/* Minimal light/dark mode for window, header, input only */ +@media (prefers-color-scheme: light) { + #chat-widget-window { + background: #FAFAFA; + color: #1A1D24; + box-shadow: 0 8px 32px rgba(26,29,36,0.10); + } + #chat-widget-header { + background: #F5F5F5; + color: #FAC13C; + } + #chat-widget-input { + background: #F5F5F5; + color: #1A1D24; + border: 1px solid #10BFCC; + } +} +@media (prefers-color-scheme: dark) { + #chat-widget-window { + background: #1A1D24; + color: #FAFAFA; + box-shadow: 0 8px 32px rgba(26,29,36,0.32); + } + #chat-widget-header { + background: #23261F; + color: #FAC13C; + } + #chat-widget-input { + background: #23261F; + color: #FAFAFA; + border: 1px solid #10BFCC; + } +} + +/* Code block appearance for dark/light mode */ +.chat-widget-pre-block { + background: #3A4256; + border-radius: 8px; + padding: 14px 16px 14px 16px; + margin: 8px 0; + overflow-x: auto; + position: relative; + box-shadow: 0 2px 8px rgba(16,191,204,0.04); + transition: background 0.3s; +} +@media (prefers-color-scheme: light) { + .chat-widget-pre-block { + background: #f6f7fa; + box-shadow: 0 2px 8px rgba(250,193,60,0.04); + } +} + +/* Markdown and code block styling */ +.chat-widget-bubble :is(h1, h2, h3, h4, h5, h6) { + margin: 0.5em 0 0.3em 0; + font-weight: 600; + color: #FAC13C; +} +.chat-widget-bubble p { + margin: 0.3em 0; +} +.chat-widget-bubble ul, .chat-widget-bubble ol { + margin: 0.5em 0 0.5em 1.2em; +} +.chat-widget-bubble a { + color: #10BFCC; + text-decoration: underline; + word-break: break-all; +} +.chat-widget-bubble code { + background: #23261F; + color: #FAC13C; + border-radius: 4px; + padding: 2px 6px; + font-size: 0.96em; +} +.chat-widget-pre-block pre { + background: none; + margin: 0; + padding: 1.1em 2.5em 1.1em 1.1em; + color: #FAFAFA; + font-size: 0.98em; + line-height: 1.5; + font-family: 'JetBrains Mono', 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', monospace; + border: none; + border-radius: 10px; +} +.chat-widget-copy-btn { + position: absolute; + top: 8px; + right: 12px; + background: #10BFCC; + color: #1A1D24; + border: none; + border-radius: 6px; + padding: 2px 10px; + font-size: 0.95em; + cursor: pointer; + z-index: 2; + transition: background 0.18s, color 0.18s; + display: flex; + align-items: center; + gap: 4px; +} +.chat-widget-copy-btn:hover, .chat-widget-copy-btn.copied { + background: #FAC13C; + color: #1A1D24; +} +.chat-widget-copy-btn svg { + margin-right: 2px; +} + +/* Ensure SVG in chat button does not intercept pointer events */ +#chat-widget-btn svg, +#chat-widget-btn svg * { + pointer-events: none; +} +#chat-widget-btn svg { + width: 88px; + height: 88px; + display: block; +} + +/* Responsive */ +@media (max-width: 600px) { + #chat-widget-window { right: 4vw; width: 98vw; min-width: 0; max-width: 99vw; height: 80vh; } + #chat-widget-btn { right: 4vw; } +} + +/* Prevent footer center from intercepting pointer events over the chat widget */ +@media (min-width: 600px) { + .td-footer__center { + pointer-events: auto; + } + /* Only disable pointer events for the part overlapping the chat widget */ + .td-footer__center.overlapping-chat-widget { + pointer-events: none !important; + } +} + +/* Force all text in AI responses to use a visible light color (#FAFAFA) regardless of markdown or inherited styles */ +#chat-widget-window.chat-widget-light .chat-widget-ai-content-with-avatar, +#chat-widget-window.chat-widget-light .chat-widget-ai-content, +#chat-widget-window.chat-widget-light .chat-widget-ai-content * { + color: #23261F !important; + background: transparent !important; +} +#chat-widget-window.chat-widget-dark .chat-widget-ai-content-with-avatar, +#chat-widget-window.chat-widget-dark .chat-widget-ai-content, +#chat-widget-window.chat-widget-dark .chat-widget-ai-content * { + color: #FAFAFA !important; + background: transparent !important; +} + +/* Force all elements in .chat-widget-ai-content to have at most 4px margin below, and no extra bottom padding */ +.chat-widget-ai-content, +.chat-widget-ai-content > * { + margin-bottom: 4px !important; + padding-bottom: 0 !important; +} + +/* Wildly beautiful animated waiting blobs for AI response */ +/* Blob animation (wildly beautiful animated waiting blobs) commented out: replaced by spinning octagon */ +/* +.cw-blob { + width: 11px; + height: 11px; + border-radius: 50%; + background: linear-gradient(135deg, #FAC13C 0%, #10BFCC 60%, #8E949E 100%); + opacity: 0.85; + box-shadow: 0 0 12px 2px #FAC13C33, 0 0 20px 4px #10BFCC22; + animation: cw-blob-bounce 1.2s infinite cubic-bezier(.68,-0.55,.27,1.55); +} +.cw-blob1 { animation-delay: 0s; background: linear-gradient(120deg, #FAC13C, #10BFCC); } +.cw-blob2 { animation-delay: 0.18s; background: linear-gradient(120deg, #10BFCC, #FAC13C); } +.cw-blob3 { animation-delay: 0.36s; background: linear-gradient(120deg, #8E949E, #FAC13C); } +@keyframes cw-blob-bounce { + 0%, 100% { transform: translateY(0) scale(1); opacity: 0.85; } + 20% { transform: translateY(-10px) scale(1.15); opacity: 1; } + 40% { transform: translateY(2px) scale(0.98); opacity: 0.7; } + 60% { transform: translateY(-6px) scale(1.08); opacity: 0.9; } + 80% { transform: translateY(1px) scale(1.02); opacity: 0.8; } +} +*/ +.chat-widget-loading-animated { + display: flex; + align-items: center; + gap: 8px; + margin: 8px 0 4px 8px; + height: 28px; +} +.chat-widget-loading-animated-small { + display: flex; + justify-content: center; + gap: 4px !important; + margin: 4px 0 2px 0 !important; + height: 16px !important; +} + +.chat-widget-copy-all-btn { + display: block; + width: max-content; + margin: 10px auto 2px; + padding: 2px 8px 20px; + background: transparent; + color: currentColor; + border: 1px solid #8E949E; + border-radius: 4px; + font: inherit; + box-shadow: none; + transition: background 0.2s; +} + +/* Make AI content text visible in light mode */ +@media (prefers-color-scheme: light) { + .chat-widget-ai-content-with-avatar, + .chat-widget-ai-content, + .chat-widget-ai-content * { + color: #23261F !important; + background: transparent !important; + } +} + +.chat-widget-feedback-row { + display: flex; + gap: 16px; + justify-content: center; + align-items: center; + margin: 8px 0 8px 0; +} +.chat-widget-feedback-btn { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.92em; + font-weight: 600; + padding: 6px 14px; + border-radius: 7px; + border: none; + cursor: pointer; + background: linear-gradient(90deg, #FFF8E1, #E0F7FA); + color: #23261F; + box-shadow: 0 2px 8px rgba(16,191,204,0.04); + transition: background 0.2s, color 0.2s; + margin: 0 4px; +} +.chat-widget-feedback-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + background: #E0E0E0; + color: #8E949E; +} + +#chat-widget-chevron { + position: fixed; + z-index: 10000; + background: none; + border: none; + padding: 0; + cursor: pointer; + outline: none; + opacity: 0.85; + transition: opacity 0.2s, background 0.2s; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(16,191,204,0.08); + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + /* Move chevron/circle down 5px */ + /* The JS positions it, so we add offset in JS */ +} +#chat-widget-chevron:hover, #chat-widget-chevron:focus { + background: #FAC13C; + opacity: 0.65; + box-shadow: none; +} +#chat-widget-chevron svg { + display: block; +} + +/* Rolling logo animation across chat bubble */ +.chat-widget-avatar-roll { + position: relative; + width: 100%; + overflow: hidden; + height: 32px !important; + --svg-width: 32px; /* default svg width for bounce calculation */ + animation: chat-widget-edge-glow calc(var(--roll-duration,8s) + 0.5s) linear infinite alternate; +} +.chat-widget-avatar-roll svg { + position: absolute; + top: 0; + left: 0; + transform-origin: center center; + /* Combined slide+spin: flips direction at edges */ + animation: chat-widget-roll var(--roll-duration,8s) linear infinite alternate !important; +} +@keyframes chat-widget-roll { + 0% { + left: 0; + transform: rotate(0deg) scaleX(0.8); + } + 5% { + transform: rotate(calc(var(--spin-end) * 0.05)) scaleX(1); + } + 95% { + transform: rotate(calc(var(--spin-end) * 0.95)) scaleX(1); + } + 100% { + left: calc(100% - var(--svg-width)); + transform: rotate(var(--spin-end,720deg)) scaleX(0.8); + } +} + +@keyframes chat-widget-edge-glow { + 0% { box-shadow: inset 8px 0 0px #10BFCC, inset 4px 0 0px #FAC13C; } + 5%, 95% { box-shadow: none; } + 100% { box-shadow: inset -8px 0 0px #10BFCC, inset -4px 0 0px #FAC13C; } +} + +/* Import Source Sans Pro and JetBrains Mono from Google Fonts */ +@import url('https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap'); + +#chat-widget-window, #chat-widget-btn, #chat-widget-header, #chat-widget-input, #chat-widget-form, #chat-widget-messages, .chat-widget-bubble, .chat-widget-user-text, .chat-widget-ai-content, .chat-widget-support-bubble { + font-family: 'Source Sans Pro', Arial, sans-serif !important; +} + +/* Use JetBrains Mono for code blocks in markdown */ +.chat-widget-ai-content code, .chat-widget-ai-content pre, .chat-widget-bubble code, .chat-widget-bubble pre { + font-family: 'JetBrains Mono', 'Fira Mono', 'Menlo', 'Monaco', 'Consolas', monospace !important; + font-size: 0.92em; + font-variant-ligatures: none; + letter-spacing: 0.01em; + background: #FAFAFA !important; /* Moon-50 */ + color: #23261F !important; +} + +/* Make sure code blocks are readable and friendly */ +.chat-widget-ai-content pre, .chat-widget-bubble pre { + background: #FAFAFA !important; /* Moon-50 */ + color: #23261F !important; + border-radius: 8px; + padding: 12px 16px; + overflow-x: auto; + margin: 1em 0; + font-size: 0.92em; +} + +.chat-widget-avatar-spin svg { + animation: chat-widget-spin 1.2s linear infinite; + display: block; + margin: 0 auto; +} +@keyframes chat-widget-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Clear Chat button in header */ +#chat-widget-header .chat-widget-header-btn { + background: transparent; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + padding: 2px 6px; + cursor: pointer; + font: inherit; + font-size: 0.85em; + font-weight: normal; +} +#chat-widget-header .chat-widget-header-btn:hover { + background: transparent; + color: inherit; +} +/* Per-theme hover overlays */ +#chat-widget-window.chat-widget-light #chat-widget-header .chat-widget-header-btn:hover { + background: rgba(0,0,0,0.05); +} +#chat-widget-window.chat-widget-dark #chat-widget-header .chat-widget-header-btn:hover { + background: rgba(255,255,255,0.1); +} + +#chat-widget-header .chat-widget-header-title { + color: inherit; +} +#chat-widget-window.chat-widget-dark #chat-widget-header .chat-widget-header-title { + color: #FAFAFA !important; +} +#chat-widget-window.chat-widget-dark #chat-widget-header .chat-widget-header-btn { + color: #FAFAFA !important; +} diff --git a/static/chat-widget.js b/static/chat-widget.js new file mode 100644 index 0000000000..2c0773f0de --- /dev/null +++ b/static/chat-widget.js @@ -0,0 +1,739 @@ +// ChatUI-inspired Beautiful Chat Widget (MIT License) +(function () { + // --- UUID v4 Generator --- + function uuidv4() { + // https://stackoverflow.com/a/2117523/438386 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } + + // --- HTML Escaping Helper --- + function escapeHtml(str) { + return String(str).replace(/[&<>"']/g, function (c) { + return {'&':'&','<':'<','>':'>','"':'"','\'':'''}[c]||c; + }); + } + + // Load marked.js from CDN if not present + if (!window.marked) { + var script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js'; + script.onload = main; + document.head.appendChild(script); + } else { + main(); + } + + // CC0 Bee SVG from SVG Repo: https://www.svgrepo.com/svg/228586/bees-bee + function beeSVG(size=28) { + return aiOctagonSVG(size); + } + + // Gradient Decahedron SVG for anonymous user + function userDecahedronSVG(size=28) { + // A decahedron (10-sided polygon) with a gradient fill + return ` + + + + + + + + + `; + } + + // Large black octagon for AI avatar (gold border, gold W&B logo centered, scaled up) + function aiOctagonSVG(size=72) { + // Larger octagon for chat-widget-btn + return ` + + + + + + + + + + + + + + + + `; + } + + // Octagon with W&B logo for AI avatar + function wandbLogoOctagonSVG(size=44) { + // Even larger octagon (44x44), W&B logo remains the same size and position + return ` + + + + + + + + + + + + + + + + `; + } + + // --- Support Widget Helper --- + function supportWidget(text) { + return ` +
+ Support request + ${text} +
+ `; + } + + function main() { + const BACKEND_URL = window.DOCS_AGENT_BACKEND_URL || 'http://localhost:8018/docs-agent'; // Change to your backend endpoint + + // --- Theme Detection & Sync with Site --- + function getSiteTheme() { + const html = document.documentElement; + const body = document.body; + // Bootstrap 5.3+ uses data-bs-theme, older may use class 'dark'/'light' + const attrTheme = html.getAttribute('data-bs-theme') || body.getAttribute('data-bs-theme'); + if (attrTheme === 'dark' || attrTheme === 'light') return attrTheme; + if (html.classList.contains('dark') || body.classList.contains('dark')) return 'dark'; + if (html.classList.contains('light') || body.classList.contains('light')) return 'light'; + return null; // fallback to system + } + function applyChatTheme(win) { + win.classList.remove('chat-widget-dark', 'chat-widget-light'); + const theme = getSiteTheme(); + if (theme === 'dark') win.classList.add('chat-widget-dark'); + else if (theme === 'light') win.classList.add('chat-widget-light'); + } + + // --- DOM Creation --- + // Floating button + const chatBtn = document.createElement('button'); + chatBtn.id = 'chat-widget-btn'; + chatBtn.setAttribute('aria-label', 'Open chat'); + chatBtn.innerHTML = wandbLogoOctagonSVG(88); + document.body.appendChild(chatBtn); + const chatBtnIcon = chatBtn.querySelector('svg'); + if (chatBtnIcon) { + chatBtnIcon.style.position = 'relative'; + chatBtnIcon.style.left = '0.75px'; + // chatBtnIcon.style.top = '0px'; + } + + // Chat window + const chatWin = document.createElement('div'); + chatWin.id = 'chat-widget-window'; + chatWin.innerHTML = ` +
+
${wandbLogoOctagonSVG(32)} W&B Agent
+ +
+
+
+
+
+ + +
+ `; + document.body.appendChild(chatWin); + chatWin.style.display = 'none'; + chatWin.style.fontFamily = "'Source Sans Pro', Arial, sans-serif"; + + // Chevron under chat window, aligned under send button + const getChatSendBtn = () => chatWin.querySelector('#chat-widget-form button[type="submit"]'); + const chatChevron = document.createElement('button'); + chatChevron.id = 'chat-widget-chevron'; + chatChevron.setAttribute('aria-label', 'Minimize chat'); + chatChevron.innerHTML = ``; + chatChevron.style.display = 'none'; + document.body.appendChild(chatChevron); + + function positionChevron() { + const sendBtn = getChatSendBtn(); + if (!sendBtn) return; + const rect = sendBtn.getBoundingClientRect(); + chatChevron.style.position = 'fixed'; + chatChevron.style.left = `${rect.left + rect.width / 2 - 4}px`; + chatChevron.style.top = `${rect.bottom + 16}px`; // Move chevron/circle down 3px more + } + + // --- Theme on load and observer --- + applyChatTheme(chatWin); + const observer = new MutationObserver(() => applyChatTheme(chatWin)); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-bs-theme'] }); + observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-bs-theme'] }); + + // --- UI Interactions --- + chatBtn.onclick = () => { + chatWin.style.display = 'flex'; + chatChevron.style.display = 'block'; + positionChevron(); + chatBtn.style.display = 'none'; + // Remove any existing feedback row and reset flags + const oldRow = chatWin.querySelector('.chat-widget-feedback-row'); + if (oldRow) oldRow.remove(); + feedbackGiven = false; + supportGiven = false; + setTimeout(() => focusInput(), 150); + }; + chatWin.querySelector('#chat-widget-header').onclick = () => { + chatWin.style.display = 'none'; + chatChevron.style.display = 'none'; + chatBtn.style.display = 'flex'; + }; + chatChevron.onclick = () => { + chatWin.style.display = 'none'; + chatChevron.style.display = 'none'; + chatBtn.style.display = 'flex'; + }; + window.addEventListener('resize', () => { + if (chatWin.style.display === 'flex') positionChevron(); + }); + // --- Keyboard shortcut (Cmd/Ctrl+M) to toggle chat --- + document.addEventListener('keydown', function(e) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'm') { + const tag = document.activeElement.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA') { + if (chatWin.style.display === 'flex') chatChevron.click(); + else chatBtn.click(); + e.preventDefault(); + } + } + }); + function focusInput() { + const input = chatWin.querySelector('#chat-widget-input'); + if (input) input.focus(); + } + + // --- Gradient bar animation control --- + const gradientBar = chatWin.querySelector('.chat-widget-gradient-bar'); + function setGradientBarFast(isFast) { + if (!gradientBar) return; + if (isFast) gradientBar.classList.add('chat-widget-gradient-fast'); + else gradientBar.classList.remove('chat-widget-gradient-fast'); + } + + // --- Markdown Rendering --- + function renderMarkdown(md) { + if (!window.marked) { + console.warn('marked.js not loaded, rendering as plain text.'); + return escapeHtml(md); + } + try { + return window.marked.parse(md, { breaks: true }); + } catch (e) { + console.error('Markdown parse error:', e); + return escapeHtml(md); + } + } + + // --- Enhance Code Blocks --- + function enhanceCodeBlocks(container) { + const codeBlocks = container.querySelectorAll('pre code'); + codeBlocks.forEach(code => { + // Wrap pre in a div for styling and button + const pre = code.parentElement; + if (!pre.classList.contains('chat-widget-pre-block')) { + pre.classList.add('chat-widget-pre-block'); + // Add copy button + const copyBtn = document.createElement('button'); + copyBtn.className = 'chat-widget-copy-btn'; + copyBtn.type = 'button'; + copyBtn.innerHTML = ''; + copyBtn.title = 'Copy code'; + copyBtn.onclick = function(e) { + e.stopPropagation(); + navigator.clipboard.writeText(code.innerText); + copyBtn.classList.add('copied'); + copyBtn.innerText = 'Copied!'; + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyBtn.innerHTML = ''; + }, 1200); + }; + pre.style.position = 'relative'; + pre.appendChild(copyBtn); + } + }); + // Add or update the Copy All Code button at the bottom if any code blocks exist + let allBtn = container.querySelector('.chat-widget-copy-all-btn'); + if (codeBlocks.length > 0) { + if (!allBtn) { + allBtn = document.createElement('button'); + allBtn.className = 'chat-widget-copy-all-btn'; + allBtn.type = 'button'; + allBtn.innerHTML = 'Copy all code'; + allBtn.onclick = function(e) { + e.preventDefault(); + e.stopPropagation(); + let allCode = Array.from(codeBlocks).map(cb => cb.innerText).join('\n\n'); + navigator.clipboard.writeText(allCode); + allBtn.classList.add('copied'); + allBtn.innerText = 'Copied!'; + setTimeout(() => { + allBtn.classList.remove('copied'); + allBtn.innerHTML = 'Copy all code'; + }, 1200); + }; + // append copy-all button under AI-generated content + const contentEl = container.querySelector('.chat-widget-ai-content') || container; + contentEl.appendChild(allBtn); + } + } else if (allBtn) { + allBtn.remove(); + } + } + + // --- Agent Tag Detection Anywhere in Text (revert to remove all occurrences) --- + function detectAgentAndCleanText(answerText) { + if (typeof answerText !== 'string') return { agent: null, text: answerText }; + const agentTags = [ + { tag: '!>', agent: 'triage_agent' }, + { tag: '!>', agent: 'support_ticket_agent' }, + ]; + for (const { tag, agent } of agentTags) { + const idx = answerText.indexOf(tag); + if (idx !== -1) { + // Remove all occurrences of the tag + const cleaned = answerText.split(tag).join('').trim(); + return { agent, text: cleaned }; + } + } + return { agent: null, text: answerText }; + } + + // --- Messaging Logic --- + const msgArea = chatWin.querySelector('#chat-widget-messages'); + const chatForm = chatWin.querySelector('#chat-widget-form'); + const chatInput = chatWin.querySelector('#chat-widget-input'); + const chatSendBtn = chatForm.querySelector('button[type="submit"]'); + const MAXLEN = 1000000; + let isOverflow = false; + let isWaiting = false; + + // --- Conversation History State --- + let chatHistory = []; + let currentConversationId = null; + let justCleared = false; + let currentAbortController = null; + + // Ensure a conversation ID is set on widget load + function ensureConversationId() { + if (!currentConversationId) { + currentConversationId = uuidv4(); + } + } + ensureConversationId(); + + // Clear chat button functionality + const clearBtn = chatWin.querySelector('#chat-widget-clear'); + clearBtn.onclick = e => { + e.stopPropagation(); + msgArea.innerHTML = ''; + chatHistory = []; + currentConversationId = uuidv4(); // Generate new conversation id + justCleared = true; + // Abort any in-flight request + if (currentAbortController) { + console.log('[chat-widget] Aborting previous request'); + currentAbortController.abort(); + currentAbortController = null; + } + isWaiting = false; + updateSendBtn(); + // Remove feedback buttons until AI responds + const oldRow2 = chatWin.querySelector('.chat-widget-feedback-row'); + if (oldRow2) oldRow2.remove(); + feedbackGiven = false; + supportGiven = false; + }; + + // --- Feedback/Support Buttons State --- + let feedbackGiven = false; + let supportGiven = false; + + function renderFeedbackButtons() { + // Only add if not already present and after first AI response + if (document.querySelector('.chat-widget-feedback-row')) return; + const row = document.createElement('div'); + row.className = 'chat-widget-feedback-row'; + // Support button + const supportBtn = document.createElement('button'); + supportBtn.className = 'chat-widget-feedback-btn'; + supportBtn.innerHTML = '👋 Open support ticket'; + supportBtn.disabled = supportGiven; + supportBtn.onclick = async function() { + if (supportGiven) return; + supportGiven = true; + supportBtn.disabled = true; + // Show a temporary message + appendMsg('user', 'Requesting support ticket...'); + appendMsg('bot', animatedWaiting(), true); + scrollToBottom(); + requestAnimationFrame(() => { + const containerWidth = msgArea.clientWidth; + const svgEl = msgArea.querySelector('.chat-widget-avatar-roll svg'); + const svgWidth = svgEl ? svgEl.getBoundingClientRect().width : 32; + const spins = (containerWidth + svgWidth) / svgWidth; + const avatarRoll = msgArea.querySelector('.chat-widget-avatar-roll'); + if (avatarRoll) { + avatarRoll.style.setProperty('--roll-duration', `${spins}s`); + avatarRoll.style.setProperty('--spin-end', `${360 * spins}deg`); + } + setGradientBarFast(true); // Speed up while waiting + }); + try { + const fetchBody = { + message: 'USER REQUESTED TO OPEN A SUPPORT TICKET', + input_items: chatHistory + }; + if (currentConversationId) { + fetchBody.conversation_id = currentConversationId; + } + const response = await fetch(BACKEND_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fetchBody) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + console.log('[chat-widget] Support ticket response:', data); + + if (data.has_error) { + console.error('[chat-widget] Server error (support ticket):', data.error_message, data); + replaceLoading(`Error creating support ticket: ${escapeHtml(data.error_message || 'Unknown error')}`); + } else { + const rawAnswerText = extractAnswerString(data.answer); + const { agent, text: cleanedAnswerText } = detectAgentAndCleanText(rawAnswerText); + // Log the detected agent for debugging if needed + if (agent) { + console.log('[chat-widget] Detected agent in support response:', agent); + } + let messageContentHtml; + if (agent === 'support_ticket_agent') { + messageContentHtml = supportWidget(cleanedAnswerText); + } else { + messageContentHtml = marked.parse(cleanedAnswerText); // Standard AI styling + } + replaceLoading(messageContentHtml); // Use the generated HTML and match main chat flow + } + + } catch (err) { + console.error('[chat-widget] Fetch error (support ticket):', err); + replaceLoading('An error occurred while requesting the support ticket. Please try again later.'); + } + scrollToBottom(); + // Set up rolling and spinning animation for the waiting icon (match main chat flow) + const containerWidth = msgArea.clientWidth; + const svgEl = msgArea.querySelector('.chat-widget-avatar-roll svg'); + const svgWidth = svgEl ? svgEl.getBoundingClientRect().width : 32; + const spins = (containerWidth + svgWidth) / svgWidth; + const avatarRoll = msgArea.querySelector('.chat-widget-avatar-roll'); + if (avatarRoll) { + avatarRoll.style.setProperty('--roll-duration', `${spins}s`); + avatarRoll.style.setProperty('--spin-end', `${360 * spins}deg`); + } + setGradientBarFast(true); // Speed up while waiting + }; + // Feedback button + const happyBtn = document.createElement('button'); + happyBtn.className = 'chat-widget-feedback-btn'; + happyBtn.innerHTML = '👍 I\'m happy'; + happyBtn.disabled = feedbackGiven; + happyBtn.onclick = async function() { + if (feedbackGiven) return; + feedbackGiven = true; + happyBtn.disabled = true; + // Send feedback as a separate field + try { + await fetch(BACKEND_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: '', input_items: chatHistory, feedback: 'good' }) + }); + } catch (err) {} + }; + row.appendChild(supportBtn); + row.appendChild(happyBtn); + // Place feedback row above the input box instead of at the bottom of chatWin + const form = chatWin.querySelector('#chat-widget-form'); + if (form && form.parentNode) { + form.parentNode.insertBefore(row, form); + } else { + chatWin.appendChild(row); + } + } + + function extractAnswerString(answer) { + // If answer is a string, return as is + if (typeof answer === 'string') return answer; + // If answer is an object, return the first value (agent message) + if (typeof answer === 'object' && answer !== null) { + const keys = Object.keys(answer); + if (keys.length > 0) return answer[keys[0]]; + return '[No reply]'; + } + return '[No reply]'; + } + function extractAgentKey(answer) { + if (typeof answer === 'object' && answer !== null) { + const keys = Object.keys(answer); + if (keys.length > 0) return keys[0]; + } + return null; + } + + // --- Modified appendMsg to support support widget and left-align user text --- + function appendMsg(role, text, isHtml=false, agentKey=null) { + const msgDiv = document.createElement('div'); + msgDiv.className = 'chat-widget-msg ' + role; + if (role === 'user') { + msgDiv.innerHTML = ` +
+ ${userDecahedronSVG(28)} + ${escapeHtml(text).replace(/\n/g, '
')}
+
+ `; + } else if (role === 'support') { + msgDiv.innerHTML = supportWidget(text); + } else if (role === 'bot' && isHtml) { + msgDiv.innerHTML = text; // Insert spinner raw HTML for bot loading + } else { + if (isHtml) { + msgDiv.innerHTML = ` +
+
${text}
+
${wandbLogoOctagonSVG(28)}
+
+ `; + } else { + msgDiv.innerHTML = ` +
+
${renderMarkdown(text)}
+
${wandbLogoOctagonSVG(28)}
+
+ `; + } + } + msgArea.appendChild(msgDiv); + enhanceCodeBlocks(msgDiv); + scrollToBottom(); + } + + // Auto-grow textarea + function autoGrowTextarea(e) { + chatInput.style.height = 'auto'; + chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px'; + } + chatInput.addEventListener('input', autoGrowTextarea); + chatInput.addEventListener('paste', function(e) { + setTimeout(autoGrowTextarea, 0); + }); + // Initial grow + autoGrowTextarea(); + + // Disable send button if input is empty or whitespace or waiting for AI + function updateSendBtn() { + chatSendBtn.disabled = !chatInput.value.trim() || isOverflow || isWaiting; + } + chatInput.addEventListener('input', updateSendBtn); + updateSendBtn(); + + // Visual feedback for overflow + chatInput.addEventListener('input', function() { + isOverflow = chatInput.value.length > MAXLEN; + if (isOverflow) { + chatInput.classList.add('chat-widget-input-overflow'); + chatInput.value = chatInput.value.slice(0, MAXLEN); + // Shake animation + chatInput.classList.add('shake'); + setTimeout(() => chatInput.classList.remove('shake'), 400); + } else { + chatInput.classList.remove('chat-widget-input-overflow'); + } + updateSendBtn(); + }); + + // Keyboard navigation: Tab/Shift+Tab, Ctrl+A, Home/End work by default in textarea + chatInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + if (e.shiftKey) { + // Insert newline (default behavior) + return; + } else { + // Submit form + e.preventDefault(); + if (!chatSendBtn.disabled) chatForm.requestSubmit(); + } + } + // Tab/Shift+Tab navigation + if (e.key === 'Tab') { + if (e.shiftKey) { + // Shift+Tab: move focus to send button + chatSendBtn.focus(); + e.preventDefault(); + } + } + }); + chatSendBtn.addEventListener('keydown', function(e) { + if (e.key === 'Tab' && !e.shiftKey) { + // Tab from button goes back to textarea + chatInput.focus(); + e.preventDefault(); + } + }); + + // --- Animated Waiting Placeholder --- + function animatedWaiting() { + return ` + + ${wandbLogoOctagonSVG(32)} + + `; + } + + chatForm.onsubmit = async function (e) { + e.preventDefault(); + const input = chatWin.querySelector('#chat-widget-input'); + const sendBtn = chatWin.querySelector('#chat-widget-form button[type="submit"]'); + if (sendBtn.disabled || isWaiting) return; // Prevent double send + const msg = input.value.trim(); + if (!msg) return; + isWaiting = true; + updateSendBtn(); + appendMsg('user', msg); + input.value = ''; + autoGrowTextarea(); + updateSendBtn(); + appendMsg('bot', animatedWaiting(), true); + // Dynamic slide-duration and spin based on container width + const containerWidth = msgArea.clientWidth; + const svgEl = msgArea.querySelector('.chat-widget-avatar-roll svg'); + const svgWidth = svgEl ? svgEl.getBoundingClientRect().width : 32; + const spins = (containerWidth + svgWidth) / svgWidth; + msgArea.querySelector('.chat-widget-avatar-roll').style.setProperty('--roll-duration', `${spins}s`); + msgArea.querySelector('.chat-widget-avatar-roll').style.setProperty('--spin-end', `${360 * spins}deg`); + setGradientBarFast(true); // Speed up while waiting + scrollToBottom(); + try { + // Abort any previous fetch + if (currentAbortController) { + console.log('[chat-widget] Aborting previous request'); + currentAbortController.abort(); + } + currentAbortController = new AbortController(); + console.log('[chat-widget] Sending request:', { msg, conversation_id: currentConversationId }); + const response = await fetch(BACKEND_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: msg, + input_items: chatHistory, + ...(currentConversationId ? { conversation_id: currentConversationId } : {}) + }), + signal: currentAbortController.signal + }); + const data = await response.json(); + console.log('[chat-widget] Received response:', data); + if (data.has_error) { + console.error('[chat-widget] Server error:', data.error_message, data); + replaceLoading(`An error occurred while processing your request. If this problem persists, please contact support@wandb.com for assistance with Weights & Biases.`); + currentAbortController = null; + setGradientBarFast(false); + scrollToBottom(); + isWaiting = false; + updateSendBtn(); + input.focus(); + return; + } + const answerText = extractAnswerString(data.answer); + // Conversation ID logic + if (data.conversation_id) { + // Only update if not just cleared + if (!justCleared) { + currentConversationId = data.conversation_id; + } + } + justCleared = false; + const { agent, text } = detectAgentAndCleanText(answerText); // agent detection + let messageContentHtml; + if (agent === 'support_ticket_agent') { + messageContentHtml = supportWidget(text); + } else { + messageContentHtml = marked.parse(text); // Standard AI styling + } + replaceLoading(messageContentHtml); + currentAbortController = null; + } catch (err) { + if (err.name === 'AbortError') { + console.log('[chat-widget] Request aborted (likely by clear). Ignoring response.'); + return; + } + console.error('[chat-widget] Error during fetch:', err); + replaceLoading('[Error: Could not reach backend]'); + } + setGradientBarFast(false); // Slow down when done + scrollToBottom(); + isWaiting = false; + updateSendBtn(); + input.focus(); + }; + + function replaceLoading(text) { + const loading = msgArea.querySelector('.chat-widget-loading-animated'); + if (loading) { + const parent = loading.parentElement; + parent.innerHTML = `
${text}
`; + enhanceCodeBlocks(parent); + // Add feedback/support buttons after first AI response + if (!feedbackGiven && !supportGiven) renderFeedbackButtons(); + } + setGradientBarFast(false); // Slow down when done + } + function scrollToBottom() { + msgArea.scrollTop = msgArea.scrollHeight; + } + + // --- Accessibility: ESC closes chat, Enter submits --- + chatWin.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + chatWin.style.display = 'none'; + chatChevron.style.display = 'none'; + chatBtn.style.display = 'flex'; + } + }); + + // --- Responsive: close on outside click (mobile) --- + window.addEventListener('click', function(e) { + if (chatWin.style.display === 'flex' && !chatWin.contains(e.target) && e.target !== chatBtn) { + chatWin.style.display = 'none'; + chatChevron.style.display = 'none'; + chatBtn.style.display = 'flex'; + } + }); + } +})();