diff --git a/embed/README.md b/embed/README.md
new file mode 100644
index 0000000..c095613
--- /dev/null
+++ b/embed/README.md
@@ -0,0 +1,199 @@
+# QP Chat Widget
+
+A portable, embeddable chat widget for QP assistants. Drop it into any website to add an AI chat assistant.
+
+## 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
+- **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 (shared across pages and pop-out windows)
+
+## 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
+ 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)
+});
+```
+
+## 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;
+}
+```
+
+## 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:
+- `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..15dfd7c
--- /dev/null
+++ b/embed/examples/basic.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ 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
+
+ Markdown rendering
+ Streaming responses
+ Suggested questions
+ Resizable window
+ Pop-out to new window (click the arrow icon in the header)
+ Mobile responsive
+ Dark mode support
+
+
+ Try Dark Mode
+
+ Toggle Dark Mode
+
+
+
+
+
+
diff --git a/embed/qp-chat-widget.css b/embed/qp-chat-widget.css
new file mode 100644
index 0000000..060f71a
--- /dev/null
+++ b/embed/qp-chat-widget.css
@@ -0,0 +1,771 @@
+/**
+ * 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; }
+
+/* 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;
+ 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;
+}
+
+/* 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
new file mode 100644
index 0000000..0991ffd
--- /dev/null
+++ b/embed/qp-chat-widget.js
@@ -0,0 +1,801 @@
+/**
+ * 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',
+ 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
+ }
+ };
+
+ // 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: ' ',
+ popout: ' '
+ };
+
+ // 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.join('') + ' ';
+ 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()) + '' + tag + '>';
+ });
+ 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 =>
+ `${escapeHtml(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');
+ }
+ }
+
+ // 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)}
+
+
+
+