From c7e5821f66cfc6ddb3c7d678d17e432f6dffc1ba Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Tue, 2 Dec 2025 23:09:55 -0800 Subject: [PATCH 1/3] Add embeddable chat widget for QP assistants Standalone vanilla JS/CSS widget that can be dropped into any website: - Configurable for any QP assistant - Markdown rendering (headers, lists, tables, code blocks) - Streaming responses - Suggested questions - Resizable window - Mobile responsive - Dark mode support (auto-detects theme) - Persistent chat history --- embed/README.md | 177 ++++++++++ embed/examples/basic.html | 65 ++++ embed/qp-chat-widget.css | 707 ++++++++++++++++++++++++++++++++++++++ embed/qp-chat-widget.js | 668 +++++++++++++++++++++++++++++++++++ 4 files changed, 1617 insertions(+) create mode 100644 embed/README.md create mode 100644 embed/examples/basic.html create mode 100644 embed/qp-chat-widget.css create mode 100644 embed/qp-chat-widget.js diff --git a/embed/README.md b/embed/README.md new file mode 100644 index 0000000..72e45cd --- /dev/null +++ b/embed/README.md @@ -0,0 +1,177 @@ +# QP Chat Widget + +A portable, embeddable chat widget for QP assistants. Drop it into any website to add an AI chat assistant. + +**Author:** Seyed Yahya Shirazi ([neuromechanist.github.io](https://neuromechanist.github.io)) + +## Features + +- Standalone vanilla JavaScript (no frameworks required) +- Configurable for any QP assistant +- Markdown rendering (headers, lists, tables, code blocks) +- Streaming responses +- Suggested questions +- Resizable chat window +- Mobile responsive +- Dark mode support (auto-detects `data-bs-theme`, `data-theme`, or `.dark` class) +- Persistent chat history via localStorage + +## Quick Start + +Add the widget to your HTML page: + +```html + + + + + + + + +``` + +## Configuration Options + +```javascript +QPChat.init({ + // API Configuration + apiEndpoint: 'https://qp-worker.neurosift.app/api/completion', // QP API endpoint + model: 'openai/gpt-4o-mini', // Model to use + provider: 'Cerebras', // Optional: LLM provider + app: 'my-assistant', // Optional: App identifier for API key routing + + // Assistant Configuration + systemPrompt: 'You are a helpful assistant.', + initialMessage: 'Hi! How can I help you today?', + suggestedQuestions: [ + 'What can you help me with?', + 'Tell me about yourself', + 'How do I get started?' + ], + + // Display + title: 'Chat Assistant', // Header title + subtitle: null, // Optional: Custom subtitle (replaces status) + position: 'bottom-right', // Widget position + + // Storage + storageKey: 'qp-chat-history', // localStorage key for chat history + + // Theming + theme: { + primaryColor: '#055c9d', // Primary color for buttons/accents + darkMode: false // Not used; dark mode auto-detected from page + }, + + // Advanced + cssUrl: 'qp-chat-widget.css' // Custom CSS file URL +}); +``` + +## API Methods + +```javascript +// Initialize the widget +QPChat.init(config); + +// Open the chat window +QPChat.open(); + +// Close the chat window +QPChat.close(); + +// Toggle the chat window +QPChat.toggle(); + +// Reset the conversation +QPChat.reset(); + +// Remove the widget from the page +QPChat.destroy(); +``` + +## Example: HED Assistant + +```html + + + + HED Assistant + + + +

Welcome to HED

+

Click the chat button to ask questions about HED.

+ + + + + +``` + +## Example: Custom Styling + +Override CSS variables for custom theming: + +```css +:root { + --qp-primary: #7c3aed; /* Purple theme */ + --qp-primary-hover: #6d28d9; + --qp-bg-light: #fafafa; + --qp-bg-white: #ffffff; + --qp-border: #e5e7eb; + --qp-text: #1f2937; + --qp-text-muted: #6b7280; + --qp-text-light: #9ca3af; +} +``` + +## Dark Mode + +The widget automatically detects dark mode from: +- `data-bs-theme="dark"` on `` (Bootstrap 5) +- `data-theme="dark"` on `` (common pattern) +- `.dark` class on `` or `` (Tailwind CSS) + +## Browser Support + +- Chrome 80+ +- Firefox 75+ +- Safari 13+ +- Edge 80+ + +## Self-Hosting + +1. Download `qp-chat-widget.js` and `qp-chat-widget.css` +2. Host them on your server or CDN +3. Update the URLs in your HTML + +## License + +MIT License - see the main QP repository for details. diff --git a/embed/examples/basic.html b/embed/examples/basic.html new file mode 100644 index 0000000..f15740b --- /dev/null +++ b/embed/examples/basic.html @@ -0,0 +1,65 @@ + + + + + + QP Chat Widget - Basic Example + + + + +

QP Chat Widget Demo

+

This is a basic example of the QP Chat Widget. Click the chat button in the bottom-right corner to start a conversation.

+ +

Features

+ + +

Try Dark Mode

+ + + + + + diff --git a/embed/qp-chat-widget.css b/embed/qp-chat-widget.css new file mode 100644 index 0000000..8b12a6f --- /dev/null +++ b/embed/qp-chat-widget.css @@ -0,0 +1,707 @@ +/** + * QP Chat Widget Styles + * + * https://github.com/magland/qp + * Author: Seyed Yahya Shirazi (https://neuromechanist.github.io) + */ + +/* CSS Variables for theming */ +:root { + --qp-primary: #055c9d; + --qp-primary-hover: #044a7d; + --qp-bg-light: #f5f5f5; + --qp-bg-white: #ffffff; + --qp-border: #d0d0d0; + --qp-text: #333333; + --qp-text-muted: #666666; + --qp-text-light: #888888; +} + +/* Floating toggle button */ +.qp-chat-toggle { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + width: 56px; + height: 56px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.qp-chat-toggle.closed { + background: var(--qp-primary); + color: #ffffff; +} + +.qp-chat-toggle.closed:hover { + transform: scale(1.1); + background: var(--qp-primary-hover); +} + +.qp-chat-toggle.open { + background: var(--qp-primary); + color: #ffffff; + transform: rotate(90deg); +} + +.qp-chat-toggle svg { + width: 24px; + height: 24px; +} + +/* Chat window */ +.qp-chat-window { + position: fixed; + bottom: 6rem; + right: 1rem; + left: auto; + width: 380px; + max-width: calc(100vw - 2rem); + height: 550px; + max-height: 70vh; + background: var(--qp-bg-light); + border: 1px solid var(--qp-border); + border-radius: 1rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + z-index: 9998; + display: flex; + flex-direction: column; + overflow: hidden; + animation: qp-chat-fade-in 0.3s ease; +} + +.qp-chat-window.hidden { + display: none; +} + +@keyframes qp-chat-fade-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Header */ +.qp-chat-header { + padding: 1rem; + border-bottom: 1px solid var(--qp-border); + background: var(--qp-primary); + display: flex; + align-items: center; + gap: 0.75rem; +} + +.qp-chat-avatar { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; +} + +.qp-chat-avatar svg { + width: 24px; + height: 24px; + color: #ffffff; +} + +.qp-chat-title { + flex: 1; +} + +.qp-chat-title-text { + font-weight: 600; + color: #ffffff; + font-size: 0.9rem; + display: block; +} + +.qp-chat-status { + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + gap: 0.25rem; +} + +.qp-chat-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #22c55e; +} + +.qp-chat-status-dot.offline { background: #ef4444; } +.qp-chat-status-dot.checking { background: #f59e0b; } + +/* Reset button */ +.qp-chat-reset { + background: transparent; + border: none; + padding: 0.5rem; + border-radius: 0.5rem; + cursor: pointer; + color: rgba(255, 255, 255, 0.7); + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.qp-chat-reset:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.qp-chat-reset:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.qp-chat-reset svg { + width: 16px; + height: 16px; +} + +/* Messages area */ +.qp-chat-messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + min-width: 0; +} + +.qp-chat-message { + display: flex; + flex-direction: column; + max-width: 85%; + min-width: 0; +} + +.qp-chat-message.user { + align-self: flex-end; + align-items: flex-end; +} + +.qp-chat-message.assistant { + align-self: flex-start; + align-items: flex-start; +} + +.qp-chat-message-label { + font-size: 0.625rem; + color: #525252; + margin-bottom: 0.25rem; + padding: 0 0.25rem; +} + +.qp-chat-message-content { + padding: 0.75rem 1rem; + font-size: 0.875rem; + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + min-width: 0; + max-width: 100%; + overflow: visible; +} + +.qp-chat-message.user .qp-chat-message-content { + background: var(--qp-primary); + color: #ffffff; + border-radius: 1rem 0.25rem 1rem 1rem; +} + +.qp-chat-message.assistant .qp-chat-message-content { + background: var(--qp-bg-white); + color: var(--qp-text); + border-radius: 0.25rem 1rem 1rem 1rem; + border: 1px solid #e0e0e0; +} + +/* Loading indicator */ +.qp-chat-loading { + display: flex; + flex-direction: column; + align-self: flex-start; + align-items: flex-start; +} + +.qp-chat-loading-label { + font-size: 0.625rem; + color: #525252; + margin-bottom: 0.25rem; + padding: 0 0.25rem; +} + +.qp-chat-loading-dots { + background: var(--qp-bg-white); + border: 1px solid #e0e0e0; + border-radius: 0.25rem 1rem 1rem 1rem; + padding: 0.75rem 1rem; + display: flex; + gap: 0.25rem; +} + +.qp-chat-loading-dot { + width: 6px; + height: 6px; + background: var(--qp-primary); + border-radius: 50%; + animation: qp-chat-bounce 1.4s infinite ease-in-out; +} + +.qp-chat-loading-dot:nth-child(1) { animation-delay: 0s; } +.qp-chat-loading-dot:nth-child(2) { animation-delay: 0.15s; } +.qp-chat-loading-dot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes qp-chat-bounce { + 0%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-6px); } +} + +/* Input area */ +.qp-chat-input-area { + padding: 1rem; + border-top: 1px solid var(--qp-border); + background: var(--qp-bg-white); +} + +.qp-chat-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.qp-chat-input { + width: 100%; + background: var(--qp-bg-light); + border: 1px solid var(--qp-border); + border-radius: 0.75rem; + padding: 0.75rem 3rem 0.75rem 1rem; + color: var(--qp-text); + font-size: 0.875rem; + outline: none; + transition: border-color 0.2s ease; +} + +.qp-chat-input::placeholder { color: var(--qp-text-light); } +.qp-chat-input:focus { border-color: var(--qp-primary); } + +.qp-chat-send { + position: absolute; + right: 0.5rem; + background: var(--qp-primary); + border: none; + border-radius: 0.5rem; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease; +} + +.qp-chat-send:hover { background: var(--qp-primary-hover); } +.qp-chat-send:disabled { opacity: 0.5; cursor: not-allowed; } +.qp-chat-send svg { width: 14px; height: 14px; color: #ffffff; } + +/* Footer */ +.qp-chat-footer { + padding: 0.5rem 1rem; + text-align: center; + border-top: 1px solid #e0e0e0; + background: #f0f0f0; +} + +.qp-chat-footer a { + color: var(--qp-text-muted); + font-size: 0.625rem; + text-decoration: none; + transition: color 0.2s ease; +} + +.qp-chat-footer a:hover { color: var(--qp-primary); } + +/* Suggested questions */ +.qp-chat-suggestions { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.qp-chat-suggestions-label { + font-size: 0.625rem; + color: var(--qp-text-muted); + padding: 0 0.25rem; +} + +.qp-chat-suggestion { + text-align: left; + font-size: 0.875rem; + padding: 0.625rem 0.875rem; + background: var(--qp-bg-white); + border: 1px solid var(--qp-border); + border-radius: 0.75rem; + color: #555555; + cursor: pointer; + transition: all 0.2s ease; +} + +.qp-chat-suggestion:hover { + background: #f0f0f0; + border-color: var(--qp-primary); + color: var(--qp-primary); +} + +/* Markdown elements */ +.qp-chat-list { + margin: 0.5rem 0; + padding-left: 1.25rem; + list-style-type: disc; +} + +.qp-chat-list li { margin: 0.25rem 0; } +.qp-chat-p { margin: 0.25rem 0; } +.qp-chat-hr { border: none; border-top: 1px solid var(--qp-border); margin: 0.75rem 0; } + +.qp-chat-h1, .qp-chat-h2, .qp-chat-h3, +.qp-chat-h4, .qp-chat-h5, .qp-chat-h6 { + margin: 0.75rem 0 0.5rem 0; + font-weight: 600; + color: var(--qp-primary); +} + +.qp-chat-h1 { font-size: 1.25rem; } +.qp-chat-h2 { font-size: 1.125rem; } +.qp-chat-h3 { font-size: 1rem; } +.qp-chat-h4, .qp-chat-h5, .qp-chat-h6 { font-size: 0.9rem; } + +/* Tables */ +.qp-chat-table-wrapper { + display: block; + overflow-x: auto; + overflow-y: hidden; + margin: 0.5rem 0; + max-width: 100%; + -webkit-overflow-scrolling: touch; +} + +.qp-chat-table { + border-collapse: collapse; + font-size: 0.75rem; + width: max-content; + min-width: 100%; + table-layout: auto; +} + +.qp-chat-table th, +.qp-chat-table td { + border: 1px solid var(--qp-border); + padding: 0.375rem 0.5rem; + text-align: left; + word-break: break-word; +} + +.qp-chat-table th { + background: #f0f0f0; + font-weight: 600; + color: var(--qp-text); +} + +.qp-chat-table td { background: #fff; } +.qp-chat-table tr:nth-child(even) td { background: #fafafa; } + +/* Code blocks */ +.qp-chat-code-block { + display: block; + background: #1e1e1e; + color: #d4d4d4; + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + margin: 0.5rem 0 1rem 0; + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-size: 0.8rem; + line-height: 1.4; + max-width: 100%; + white-space: pre; + word-break: normal; + -webkit-overflow-scrolling: touch; +} + +.qp-chat-code-block code { + background: transparent; + padding: 0; + color: inherit; + white-space: inherit; +} + +.qp-chat-inline-code { + background: #f0f0f0; + color: var(--qp-primary); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: ui-monospace, 'SF Mono', Monaco, monospace; + font-size: 0.85em; + word-break: break-all; +} + +/* Resize handle */ +.qp-chat-resize-handle { + position: absolute; + top: 0; + left: 0; + width: 16px; + height: 16px; + cursor: nw-resize; + z-index: 10; +} + +.qp-chat-resize-handle::before { + content: ''; + position: absolute; + top: 4px; + left: 4px; + width: 8px; + height: 8px; + border-left: 2px solid #aaaaaa; + border-top: 2px solid #aaaaaa; + border-radius: 2px 0 0 0; +} + +.qp-chat-resize-handle:hover::before { + border-color: var(--qp-primary); +} + +/* Scrollbar */ +.qp-chat-messages::-webkit-scrollbar { width: 6px; } +.qp-chat-messages::-webkit-scrollbar-track { background: transparent; } +.qp-chat-messages::-webkit-scrollbar-thumb { + background: rgba(5, 92, 157, 0.2); + border-radius: 3px; +} +.qp-chat-messages::-webkit-scrollbar-thumb:hover { + background: rgba(5, 92, 157, 0.4); +} + +/* Mobile adjustments */ +@media (max-width: 640px) { + .qp-chat-toggle { + bottom: 1rem; + right: 1rem; + width: 48px; + height: 48px; + } + + .qp-chat-toggle svg { width: 20px; height: 20px; } + + .qp-chat-window { + bottom: 4rem; + right: 0.5rem; + left: auto; + width: 300px; + max-width: calc(100vw - 1rem); + min-width: 260px; + height: 45vh; + max-height: 50vh; + } + + .qp-chat-header { padding: 0.75rem; } + .qp-chat-messages { padding: 0.75rem; } + .qp-chat-input-area { padding: 0.75rem; } + .qp-chat-table { font-size: 0.65rem; } + .qp-chat-table th, .qp-chat-table td { padding: 0.25rem 0.375rem; } + + .qp-chat-resize-handle { width: 20px; height: 20px; } + .qp-chat-resize-handle::before { width: 10px; height: 10px; border-width: 3px; } +} + +/* Dark mode support */ +[data-bs-theme="dark"] .qp-chat-window, +[data-theme="dark"] .qp-chat-window, +.dark .qp-chat-window { + background: #1e293b; + border-color: #334155; +} + +[data-bs-theme="dark"] .qp-chat-header, +[data-theme="dark"] .qp-chat-header, +.dark .qp-chat-header { + background: #0f172a; + border-color: #334155; +} + +[data-bs-theme="dark"] .qp-chat-messages, +[data-theme="dark"] .qp-chat-messages, +.dark .qp-chat-messages { + background: #1e293b; +} + +[data-bs-theme="dark"] .qp-chat-message.assistant .qp-chat-message-content, +[data-theme="dark"] .qp-chat-message.assistant .qp-chat-message-content, +.dark .qp-chat-message.assistant .qp-chat-message-content { + background: #334155; + color: #e2e8f0; + border-color: #475569; +} + +[data-bs-theme="dark"] .qp-chat-message.user .qp-chat-message-content, +[data-theme="dark"] .qp-chat-message.user .qp-chat-message-content, +.dark .qp-chat-message.user .qp-chat-message-content { + background: #3b82f6; +} + +[data-bs-theme="dark"] .qp-chat-input-area, +[data-theme="dark"] .qp-chat-input-area, +.dark .qp-chat-input-area { + background: #0f172a; + border-color: #334155; +} + +[data-bs-theme="dark"] .qp-chat-input, +[data-theme="dark"] .qp-chat-input, +.dark .qp-chat-input { + background: #1e293b; + border-color: #475569; + color: #e2e8f0; +} + +[data-bs-theme="dark"] .qp-chat-input::placeholder, +[data-theme="dark"] .qp-chat-input::placeholder, +.dark .qp-chat-input::placeholder { + color: #94a3b8; +} + +[data-bs-theme="dark"] .qp-chat-footer, +[data-theme="dark"] .qp-chat-footer, +.dark .qp-chat-footer { + background: #0f172a; + border-color: #334155; +} + +[data-bs-theme="dark"] .qp-chat-footer a, +[data-theme="dark"] .qp-chat-footer a, +.dark .qp-chat-footer a { + color: #94a3b8; +} + +[data-bs-theme="dark"] .qp-chat-suggestion, +[data-theme="dark"] .qp-chat-suggestion, +.dark .qp-chat-suggestion { + background: #334155; + border-color: #475569; + color: #e2e8f0; +} + +[data-bs-theme="dark"] .qp-chat-suggestion:hover, +[data-theme="dark"] .qp-chat-suggestion:hover, +.dark .qp-chat-suggestion:hover { + background: #475569; + border-color: #3b82f6; + color: #3b82f6; +} + +[data-bs-theme="dark"] .qp-chat-suggestions-label, +[data-theme="dark"] .qp-chat-suggestions-label, +.dark .qp-chat-suggestions-label, +[data-bs-theme="dark"] .qp-chat-message-label, +[data-theme="dark"] .qp-chat-message-label, +.dark .qp-chat-message-label { + color: #94a3b8; +} + +[data-bs-theme="dark"] .qp-chat-loading-dots, +[data-theme="dark"] .qp-chat-loading-dots, +.dark .qp-chat-loading-dots { + background: #334155; + border-color: #475569; +} + +[data-bs-theme="dark"] .qp-chat-inline-code, +[data-theme="dark"] .qp-chat-inline-code, +.dark .qp-chat-inline-code { + background: #334155; + color: #3b82f6; +} + +[data-bs-theme="dark"] .qp-chat-table th, +[data-theme="dark"] .qp-chat-table th, +.dark .qp-chat-table th { + background: #334155; + color: #e2e8f0; +} + +[data-bs-theme="dark"] .qp-chat-table td, +[data-theme="dark"] .qp-chat-table td, +.dark .qp-chat-table td { + background: #1e293b; + color: #e2e8f0; +} + +[data-bs-theme="dark"] .qp-chat-table tr:nth-child(even) td, +[data-theme="dark"] .qp-chat-table tr:nth-child(even) td, +.dark .qp-chat-table tr:nth-child(even) td { + background: #273449; +} + +[data-bs-theme="dark"] .qp-chat-table th, +[data-bs-theme="dark"] .qp-chat-table td, +[data-theme="dark"] .qp-chat-table th, +[data-theme="dark"] .qp-chat-table td, +.dark .qp-chat-table th, +.dark .qp-chat-table td { + border-color: #475569; +} + +[data-bs-theme="dark"] .qp-chat-h1, +[data-bs-theme="dark"] .qp-chat-h2, +[data-bs-theme="dark"] .qp-chat-h3, +[data-bs-theme="dark"] .qp-chat-h4, +[data-bs-theme="dark"] .qp-chat-h5, +[data-bs-theme="dark"] .qp-chat-h6, +[data-theme="dark"] .qp-chat-h1, +[data-theme="dark"] .qp-chat-h2, +[data-theme="dark"] .qp-chat-h3, +[data-theme="dark"] .qp-chat-h4, +[data-theme="dark"] .qp-chat-h5, +[data-theme="dark"] .qp-chat-h6, +.dark .qp-chat-h1, +.dark .qp-chat-h2, +.dark .qp-chat-h3, +.dark .qp-chat-h4, +.dark .qp-chat-h5, +.dark .qp-chat-h6 { + color: #3b82f6; +} + +[data-bs-theme="dark"] .qp-chat-hr, +[data-theme="dark"] .qp-chat-hr, +.dark .qp-chat-hr { + border-color: #475569; +} + +[data-bs-theme="dark"] .qp-chat-resize-handle::before, +[data-theme="dark"] .qp-chat-resize-handle::before, +.dark .qp-chat-resize-handle::before { + border-color: #64748b; +} + +[data-bs-theme="dark"] .qp-chat-resize-handle:hover::before, +[data-theme="dark"] .qp-chat-resize-handle:hover::before, +.dark .qp-chat-resize-handle:hover::before { + border-color: #3b82f6; +} diff --git a/embed/qp-chat-widget.js b/embed/qp-chat-widget.js new file mode 100644 index 0000000..ea45e06 --- /dev/null +++ b/embed/qp-chat-widget.js @@ -0,0 +1,668 @@ +/** + * QP Chat Widget + * A portable, embeddable chat widget for QP assistants. + * + * https://github.com/magland/qp + * Author: Seyed Yahya Shirazi (https://neuromechanist.github.io) + */ + +(function(global) { + 'use strict'; + + // Default configuration + const DEFAULT_CONFIG = { + apiEndpoint: 'https://qp-worker.neurosift.app/api/completion', + model: 'openai/gpt-4o-mini', + provider: null, + app: null, + systemPrompt: 'You are a helpful assistant.', + initialMessage: 'Hi! How can I help you today?', + suggestedQuestions: [], + title: 'Chat Assistant', + subtitle: null, + position: 'bottom-right', + storageKey: 'qp-chat-history', + theme: { + primaryColor: '#055c9d', + darkMode: false + } + }; + + // Current configuration + let config = { ...DEFAULT_CONFIG }; + + // State + let isOpen = false; + let isLoading = false; + let messages = []; + let backendOnline = false; + let widgetCreated = false; + + // Icons (SVG) + const ICONS = { + chat: '', + close: '', + send: '', + brain: '', + reset: '' + }; + + // Load chat history from localStorage + function loadHistory() { + try { + const saved = localStorage.getItem(config.storageKey); + if (saved) { + messages = JSON.parse(saved); + } + } catch (e) { + console.warn('Failed to load chat history:', e); + } + if (messages.length === 0) { + messages = [{ + role: 'assistant', + content: config.initialMessage + }]; + } + } + + // Save chat history to localStorage + function saveHistory() { + try { + localStorage.setItem(config.storageKey, JSON.stringify(messages)); + } catch (e) { + console.warn('Failed to save chat history:', e); + } + } + + // Reset conversation + function resetConversation() { + messages = [{ + role: 'assistant', + content: config.initialMessage + }]; + saveHistory(); + renderMessages(); + } + + // Check backend connectivity + async function checkBackendStatus() { + const statusDot = document.getElementById('qp-chat-status-dot'); + const statusText = document.getElementById('qp-chat-status-text'); + + if (!statusDot || !statusText) return; + + try { + const requestBody = { + model: config.model, + systemMessage: config.systemPrompt, + messages: [{ role: 'user', content: 'ping' }], + tools: [] + }; + if (config.provider) requestBody.provider = config.provider; + if (config.app) requestBody.app = config.app; + + const response = await fetch(config.apiEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (response.ok || response.status === 200) { + backendOnline = true; + statusDot.className = 'qp-chat-status-dot'; + statusText.textContent = 'Online'; + } else { + backendOnline = false; + statusDot.className = 'qp-chat-status-dot offline'; + statusText.textContent = 'Offline'; + } + } catch (e) { + backendOnline = false; + statusDot.className = 'qp-chat-status-dot offline'; + statusText.textContent = 'Offline'; + } + + renderMessages(); + } + + // Escape HTML + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Render inline markdown + function renderInlineMarkdown(text) { + if (!text) return ''; + let result = ''; + let remaining = text; + + while (remaining.length > 0) { + const boldMatch = remaining.match(/\*\*(.+?)\*\*/); + const linkMatch = remaining.match(/\[([^\]]+)\]\(([^)]+)\)/); + const urlMatch = remaining.match(/(? i !== -1); + if (indices.length === 0) { + result += escapeHtml(remaining); + break; + } + const minIndex = Math.min(...indices); + + if (minIndex === boldIndex && boldMatch) { + if (boldIndex > 0) result += escapeHtml(remaining.substring(0, boldIndex)); + result += '' + escapeHtml(boldMatch[1]) + ''; + remaining = remaining.substring(boldIndex + boldMatch[0].length); + } else if (minIndex === linkIndex && linkMatch) { + if (linkIndex > 0) result += escapeHtml(remaining.substring(0, linkIndex)); + result += '' + escapeHtml(linkMatch[1]) + ''; + remaining = remaining.substring(linkIndex + linkMatch[0].length); + } else if (minIndex === urlIndex && urlMatch) { + if (urlIndex > 0) result += escapeHtml(remaining.substring(0, urlIndex)); + result += '' + escapeHtml(urlMatch[0]) + ''; + remaining = remaining.substring(urlIndex + urlMatch[0].length); + } + } + return result; + } + + // Markdown to HTML converter + function markdownToHtml(text) { + if (!text) return ''; + + const lines = text.split('\n'); + let result = ''; + let inCodeBlock = false; + let codeBlockContent = []; + let inTable = false; + let tableRows = []; + let currentList = []; + + const flushList = () => { + if (currentList.length > 0) { + result += ''; + currentList = []; + } + }; + + const flushTable = () => { + if (tableRows.length > 0) { + let tableHtml = '
'; + tableRows.forEach((row, idx) => { + const cells = row.split('|').filter(c => c.trim() !== ''); + if (cells.every(c => /^[\s\-:]+$/.test(c))) return; + const tag = idx === 0 ? 'th' : 'td'; + tableHtml += ''; + cells.forEach(cell => { + tableHtml += '<' + tag + '>' + renderInlineMarkdown(cell.trim()) + ''; + }); + tableHtml += ''; + }); + tableHtml += '
'; + result += tableHtml; + tableRows = []; + inTable = false; + } + }; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + + if (line.trim().startsWith('```')) { + if (inCodeBlock) { + result += '
' + escapeHtml(codeBlockContent.join('\n')) + '
'; + codeBlockContent = []; + inCodeBlock = false; + } else { + flushList(); + flushTable(); + inCodeBlock = true; + } + continue; + } + + if (inCodeBlock) { + codeBlockContent.push(line); + continue; + } + + if (line.includes('|') && (line.trim().startsWith('|') || line.match(/\|.*\|/))) { + flushList(); + inTable = true; + tableRows.push(line); + continue; + } else if (inTable) { + flushTable(); + } + + if (/^[-\*_\u2013\u2014]{3,}\s*$/.test(line.trim())) { + flushList(); + flushTable(); + result += '
'; + continue; + } + + const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headerMatch) { + flushList(); + const level = headerMatch[1].length; + result += '' + renderInlineMarkdown(headerMatch[2]) + ''; + continue; + } + + const bulletMatch = line.match(/^[\*\-]\s+(.+)$/); + if (bulletMatch) { + currentList.push('
  • ' + renderInlineMarkdown(bulletMatch[1]) + '
  • '); + continue; + } + + flushList(); + + if (line.trim()) { + let processedLine = line.replace(/`([^`]+)`/g, function(match, code) { + return '' + escapeHtml(code) + ''; + }); + processedLine = processedLine.replace(/(]*>.*?<\/code>)|([^<]+)/g, function(match, codeTag, text) { + if (codeTag) return codeTag; + if (text) return renderInlineMarkdown(text); + return match; + }); + result += '

    ' + processedLine + '

    '; + } + } + + flushList(); + flushTable(); + if (inCodeBlock && codeBlockContent.length > 0) { + result += '
    ' + escapeHtml(codeBlockContent.join('\n')) + '
    '; + } + + return result || text; + } + + // Check if should show suggestions + function shouldShowSuggestions() { + return messages.length === 1 && !isLoading && config.suggestedQuestions.length > 0; + } + + // Render messages + function renderMessages() { + const container = document.getElementById('qp-chat-messages'); + if (!container) return; + + let html = ''; + for (const msg of messages) { + const isUser = msg.role === 'user'; + const label = isUser ? 'You' : config.title; + const content = isUser ? escapeHtml(msg.content) : markdownToHtml(msg.content); + + html += ` +
    + ${escapeHtml(label)} +
    ${content}
    +
    + `; + } + + if (shouldShowSuggestions()) { + html += ` +
    + Try asking: + ${config.suggestedQuestions.map(q => + `` + ).join('')} +
    + `; + } + + if (isLoading) { + html += ` +
    + ${escapeHtml(config.title)} +
    + + + +
    +
    + `; + } + + container.innerHTML = html; + + // Add click handlers for suggestions + container.querySelectorAll('.qp-chat-suggestion').forEach(btn => { + btn.onclick = () => handleSuggestedQuestion(btn.dataset.question); + }); + + // Update reset button state + const resetBtn = document.getElementById('qp-chat-reset'); + if (resetBtn) { + resetBtn.disabled = messages.length <= 1 || isLoading; + } + + scrollToBottom(); + } + + // Scroll to bottom + function scrollToBottom() { + const container = document.getElementById('qp-chat-messages'); + if (container) { + container.scrollTop = container.scrollHeight; + } + } + + // Handle suggested question click + async function handleSuggestedQuestion(question) { + if (isLoading) return; + + messages.push({ role: 'user', content: question }); + isLoading = true; + renderMessages(); + saveHistory(); + + try { + const response = await sendMessage(); + messages.push({ role: 'assistant', content: response }); + } catch (error) { + console.error('Chat error:', error); + messages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }); + } + + isLoading = false; + renderMessages(); + saveHistory(); + } + + // Handle form submit + async function handleSubmit(e) { + e.preventDefault(); + + const input = document.getElementById('qp-chat-input'); + const text = input.value.trim(); + + if (!text || isLoading) return; + + messages.push({ role: 'user', content: text }); + input.value = ''; + isLoading = true; + renderMessages(); + saveHistory(); + + try { + const response = await sendMessage(); + messages.push({ role: 'assistant', content: response }); + } catch (error) { + console.error('Chat error:', error); + messages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }); + } + + isLoading = false; + renderMessages(); + saveHistory(); + } + + // Send message to API + async function sendMessage() { + const apiMessages = messages + .filter((_, i) => i > 0) + .map(msg => ({ role: msg.role, content: msg.content })); + + const requestBody = { + model: config.model, + systemMessage: config.systemPrompt, + messages: apiMessages, + tools: [] + }; + if (config.provider) requestBody.provider = config.provider; + if (config.app) requestBody.app = config.app; + + const response = await fetch(config.apiEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API error: ${response.status} - ${errorText}`); + } + + // Handle streaming response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullContent = ''; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta?.content; + if (delta) { + fullContent += delta; + updateStreamingMessage(fullContent); + } + } catch (e) { + // Ignore parse errors + } + } + } + } + + return fullContent || 'I received your message but had no response.'; + } + + // Update streaming message + function updateStreamingMessage(content) { + const container = document.getElementById('qp-chat-messages'); + if (!container) return; + + let streamingEl = container.querySelector('.qp-chat-streaming'); + if (!streamingEl) { + const loadingEl = container.querySelector('.qp-chat-loading'); + if (loadingEl) loadingEl.remove(); + + streamingEl = document.createElement('div'); + streamingEl.className = 'qp-chat-message assistant qp-chat-streaming'; + streamingEl.innerHTML = ` + ${escapeHtml(config.title)} +
    + `; + container.appendChild(streamingEl); + } + + const contentEl = streamingEl.querySelector('.qp-chat-message-content'); + if (contentEl) { + contentEl.innerHTML = markdownToHtml(content); + } + + scrollToBottom(); + } + + // Toggle chat window + function toggleChat() { + isOpen = !isOpen; + const toggleBtn = document.getElementById('qp-chat-toggle'); + const chatWindow = document.getElementById('qp-chat-window'); + + if (isOpen) { + toggleBtn.className = 'qp-chat-toggle open'; + toggleBtn.innerHTML = ICONS.close; + chatWindow.classList.remove('hidden'); + scrollToBottom(); + document.getElementById('qp-chat-input').focus(); + } else { + toggleBtn.className = 'qp-chat-toggle closed'; + toggleBtn.innerHTML = ICONS.chat; + chatWindow.classList.add('hidden'); + } + } + + // Setup resize functionality + function setupResize(chatWindow) { + const resizeHandle = chatWindow.querySelector('.qp-chat-resize-handle'); + if (!resizeHandle) return; + + let isResizing = false; + let startX, startY, startWidth, startHeight; + + resizeHandle.addEventListener('mousedown', (e) => { + isResizing = true; + startX = e.clientX; + startY = e.clientY; + startWidth = chatWindow.offsetWidth; + startHeight = chatWindow.offsetHeight; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + + const newWidth = startWidth - (e.clientX - startX); + const newHeight = startHeight - (e.clientY - startY); + + if (newWidth >= 280 && newWidth <= 600) { + chatWindow.style.width = newWidth + 'px'; + chatWindow.style.left = 'auto'; + } + if (newHeight >= 300 && newHeight <= 800) { + chatWindow.style.height = newHeight + 'px'; + } + }); + + document.addEventListener('mouseup', () => { + isResizing = false; + }); + } + + // Create the widget + function createWidget() { + if (widgetCreated) return; + + // Inject CSS if not already present + if (!document.getElementById('qp-chat-widget-styles')) { + const link = document.createElement('link'); + link.id = 'qp-chat-widget-styles'; + link.rel = 'stylesheet'; + link.href = (config.cssUrl || 'qp-chat-widget.css'); + document.head.appendChild(link); + } + + // Create toggle button + const toggleBtn = document.createElement('button'); + toggleBtn.id = 'qp-chat-toggle'; + toggleBtn.className = 'qp-chat-toggle closed'; + toggleBtn.setAttribute('aria-label', 'Toggle Chat Assistant'); + toggleBtn.innerHTML = ICONS.chat; + toggleBtn.onclick = toggleChat; + + // Apply custom primary color + if (config.theme.primaryColor) { + toggleBtn.style.setProperty('--qp-primary', config.theme.primaryColor); + } + + // Create chat window + const subtitle = config.subtitle ? `${escapeHtml(config.subtitle)}` : + `Checking...`; + + const chatWindow = document.createElement('div'); + chatWindow.id = 'qp-chat-window'; + chatWindow.className = 'qp-chat-window hidden'; + chatWindow.innerHTML = ` +
    +
    ${ICONS.brain}
    +
    + ${escapeHtml(config.title)} + ${subtitle} +
    + +
    +
    +
    +
    + + +
    +
    + +
    + `; + + document.body.appendChild(toggleBtn); + document.body.appendChild(chatWindow); + + // Event listeners + document.getElementById('qp-chat-form').onsubmit = handleSubmit; + document.getElementById('qp-chat-reset').onclick = () => { + if (messages.length <= 1 || isLoading) return; + resetConversation(); + }; + + setupResize(chatWindow); + widgetCreated = true; + } + + // Initialize the widget + function init(userConfig = {}) { + // Merge configurations + config = { + ...DEFAULT_CONFIG, + ...userConfig, + theme: { ...DEFAULT_CONFIG.theme, ...(userConfig.theme || {}) } + }; + + loadHistory(); + createWidget(); + renderMessages(); + + // Check backend status if no custom subtitle + if (!config.subtitle) { + checkBackendStatus(); + } + } + + // Destroy the widget + function destroy() { + const toggle = document.getElementById('qp-chat-toggle'); + const window = document.getElementById('qp-chat-window'); + const styles = document.getElementById('qp-chat-widget-styles'); + + if (toggle) toggle.remove(); + if (window) window.remove(); + if (styles) styles.remove(); + + widgetCreated = false; + isOpen = false; + messages = []; + } + + // Export API + global.QPChat = { + init: init, + destroy: destroy, + reset: resetConversation, + open: () => { if (!isOpen) toggleChat(); }, + close: () => { if (isOpen) toggleChat(); }, + toggle: toggleChat + }; + +})(typeof window !== 'undefined' ? window : this); From 15674c4f0244f0728e6fae5b57d2f5d5f0827f6a Mon Sep 17 00:00:00 2001 From: "Seyed (Yahya) Shirazi" Date: Tue, 2 Dec 2025 23:14:00 -0800 Subject: [PATCH 2/3] Update README.md for QP Chat Widget --- embed/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/embed/README.md b/embed/README.md index 72e45cd..5be2a44 100644 --- a/embed/README.md +++ b/embed/README.md @@ -2,8 +2,6 @@ A portable, embeddable chat widget for QP assistants. Drop it into any website to add an AI chat assistant. -**Author:** Seyed Yahya Shirazi ([neuromechanist.github.io](https://neuromechanist.github.io)) - ## Features - Standalone vanilla JavaScript (no frameworks required) @@ -175,3 +173,4 @@ The widget automatically detects dark mode from: ## License MIT License - see the main QP repository for details. + From ec7125b2870c6617d7621e2d84ce2bed275a8ceb Mon Sep 17 00:00:00 2001 From: Seyed Yahya Shirazi Date: Thu, 15 Jan 2026 14:12:38 -0800 Subject: [PATCH 3/3] Add pop-out button to embed widget for full-screen chat in new window - Add pop-out icon and button in chat header - Implement openPopout() that opens chat in new window with fullscreen mode - Embed CSS and JS inline in popup for reliable loading - Add fullscreen mode styles - Chat history persists via shared localStorage - Update README with pop-out documentation --- embed/README.md | 27 ++++++- embed/examples/basic.html | 1 + embed/qp-chat-widget.css | 64 +++++++++++++++++ embed/qp-chat-widget.js | 143 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 228 insertions(+), 7 deletions(-) diff --git a/embed/README.md b/embed/README.md index 5be2a44..c095613 100644 --- a/embed/README.md +++ b/embed/README.md @@ -10,9 +10,10 @@ A portable, embeddable chat widget for QP assistants. Drop it into any website t - Streaming responses - Suggested questions - Resizable chat window +- **Pop-out to new window** for side-by-side viewing with documentation - Mobile responsive - Dark mode support (auto-detects `data-bs-theme`, `data-theme`, or `.dark` class) -- Persistent chat history via localStorage +- Persistent chat history via localStorage (shared across pages and pop-out windows) ## Quick Start @@ -69,7 +70,9 @@ QPChat.init({ }, // Advanced - cssUrl: 'qp-chat-widget.css' // Custom CSS file URL + cssUrl: 'qp-chat-widget.css', // Custom CSS file URL + jsUrl: 'qp-chat-widget.js', // Custom JS file URL (used for pop-out) + fullscreen: false // When true, renders as full-page chat (for pop-out windows) }); ``` @@ -150,6 +153,26 @@ Override CSS variables for custom theming: } ``` +## Pop-out Window + +Click the pop-out button (arrow icon) in the chat header to open the chat in a new window. This allows you to: + +- View documentation and chat side-by-side +- Continue the same conversation in the pop-out window +- Resize the pop-out window independently + +The chat history is shared via localStorage, so the conversation persists between the embedded widget and the pop-out window (as long as they're on the same domain). + +**Note:** For the pop-out feature to work correctly, you should configure `jsUrl` and `cssUrl` with absolute URLs pointing to where your widget files are hosted: + +```javascript +QPChat.init({ + // ... other options + cssUrl: 'https://your-cdn.com/qp-chat-widget.css', + jsUrl: 'https://your-cdn.com/qp-chat-widget.js' +}); +``` + ## Dark Mode The widget automatically detects dark mode from: diff --git a/embed/examples/basic.html b/embed/examples/basic.html index f15740b..15dfd7c 100644 --- a/embed/examples/basic.html +++ b/embed/examples/basic.html @@ -26,6 +26,7 @@

    Features

  • Streaming responses
  • Suggested questions
  • Resizable window
  • +
  • Pop-out to new window (click the arrow icon in the header)
  • Mobile responsive
  • Dark mode support
  • diff --git a/embed/qp-chat-widget.css b/embed/qp-chat-widget.css index 8b12a6f..060f71a 100644 --- a/embed/qp-chat-widget.css +++ b/embed/qp-chat-widget.css @@ -139,6 +139,37 @@ .qp-chat-status-dot.offline { background: #ef4444; } .qp-chat-status-dot.checking { background: #f59e0b; } +/* Header actions container */ +.qp-chat-header-actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Pop-out button */ +.qp-chat-popout { + background: transparent; + border: none; + padding: 0.5rem; + border-radius: 0.5rem; + cursor: pointer; + color: rgba(255, 255, 255, 0.7); + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.qp-chat-popout:hover { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.qp-chat-popout svg { + width: 16px; + height: 16px; +} + /* Reset button */ .qp-chat-reset { background: transparent; @@ -705,3 +736,36 @@ .dark .qp-chat-resize-handle:hover::before { border-color: #3b82f6; } + +/* Fullscreen mode (for popup windows) */ +.qp-chat-window.fullscreen { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100% !important; + height: 100% !important; + max-width: none !important; + max-height: none !important; + border-radius: 0 !important; + border: none !important; + z-index: 1 !important; + animation: none !important; +} + +.qp-chat-window.fullscreen .qp-chat-resize-handle { + display: none !important; +} + +.qp-chat-window.fullscreen .qp-chat-popout { + display: none !important; +} + +.qp-chat-window.fullscreen .qp-chat-header { + border-radius: 0; +} + +.qp-chat-window.fullscreen .qp-chat-messages { + max-height: none; +} diff --git a/embed/qp-chat-widget.js b/embed/qp-chat-widget.js index ea45e06..0991ffd 100644 --- a/embed/qp-chat-widget.js +++ b/embed/qp-chat-widget.js @@ -22,6 +22,9 @@ subtitle: null, position: 'bottom-right', storageKey: 'qp-chat-history', + fullscreen: false, // When true, renders as full-page chat (for popup windows) + cssUrl: null, // Custom CSS URL (defaults to CDN) + jsUrl: null, // Custom JS URL (defaults to CDN) theme: { primaryColor: '#055c9d', darkMode: false @@ -44,7 +47,8 @@ close: '', send: '', brain: '', - reset: '' + reset: '', + popout: '' }; // Load chat history from localStorage @@ -515,6 +519,114 @@ } } + // Open chat in a new popup window + async function openPopout() { + // Extract CSS from loaded stylesheets + function extractCss() { + let css = ''; + for (const sheet of document.styleSheets) { + try { + if (sheet.href && sheet.href.includes('qp-chat-widget')) { + for (const rule of sheet.cssRules) { + css += rule.cssText + '\n'; + } + } + } catch (e) { + // Cross-origin stylesheets can't be read + } + } + return css; + } + + // Find the script URL + function getScriptUrl() { + const scripts = document.querySelectorAll('script[src*="qp-chat-widget"]'); + if (scripts.length > 0) { + const src = scripts[scripts.length - 1].src; + return src; + } + return null; + } + + // Get CSS and JS + const widgetCss = extractCss(); + const scriptUrl = getScriptUrl(); + + let jsCode = ''; + if (scriptUrl) { + try { + const response = await fetch(scriptUrl); + jsCode = await response.text(); + } catch (e) { + console.error('Failed to fetch widget script:', e); + } + } + + // Generate the popup HTML with full-page chat + const popupConfig = { + ...config, + fullscreen: true + }; + + // Create the popup content with inline CSS and JS + const popupHtml = ` + + + + + ${escapeHtml(config.title)} + + + +