aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.example.js70
-rw-r--r--src/widget.html601
2 files changed, 671 insertions, 0 deletions
diff --git a/src/config.example.js b/src/config.example.js
new file mode 100644
index 0000000..84d2a01
--- /dev/null
+++ b/src/config.example.js
@@ -0,0 +1,70 @@
+/* ==========================================================================
+ * WebX11 Chat Widget — configuration
+ * --------------------------------------------------------------------------
+ * Copy this file to `config.js` next to widget.html and edit the values.
+ * Everything is optional except `endpoint`.
+ *
+ * ABOUT THE ENDPOINT
+ * `endpoint` must be YOUR OWN gateway, i.e. an HTTP URL on your server that
+ * forwards the request to your model backend (Ollama, an Ollama-compatible
+ * API, or your own proxy) and adds your API key server-side.
+ *
+ * The widget runs in the visitor's browser, so anything you put in this
+ * file is public. NEVER put an API key, token or password here — not in
+ * this example file, not in your config.js, not in widget.html. Keep the
+ * key on the server (environment variable, secrets file, systemd unit)
+ * and let the gateway attach it to the upstream request.
+ *
+ * The endpoint must also allow your website's origin (CORS). See
+ * docs/USAGE.md → Troubleshooting.
+ * ========================================================================== */
+
+window.WX_WIDGET_CONFIG = {
+
+ /* Base URL of your gateway: protocol + host + the path prefix your proxy
+ * exposes, e.g. 'https://YOUR-HOST/ollama'. The widget appends
+ * '/api/generate' for you. If you prefer, give the full generate URL
+ * ('https://YOUR-HOST/ollama/api/generate') and it is used as-is.
+ * A relative path such as '/ollama' works when the widget is served from
+ * the same origin as the gateway. */
+ endpoint: 'https://YOUR-HOST/ollama',
+
+ /* Model name as your backend knows it. Ollama tags look like
+ * 'llama3.2:latest'. This is a public identifier, not a secret. */
+ model: 'llama3.2:latest',
+
+ /* Header and greeting */
+ title: 'Chat with us',
+ welcomeMessage: 'Hi! How can I help you today?',
+ inputPlaceholder: 'Type your message…',
+
+ /* Brand colour (any #rgb or #rrggbb). Text on the accent colour is
+ * switched between dark and light automatically. */
+ accentColor: '#00e5a0',
+
+ /* 'right' (default) or 'left' */
+ position: 'right',
+
+ /* Show the "Model: …" line under the header. Set to false to hide it. */
+ statusLine: true,
+
+ /* The assistant's instructions. Write this for your own business. */
+ systemPrompt:
+ 'You are a helpful assistant for this website. ' +
+ 'Answer briefly and accurately. If you do not know something, say so ' +
+ 'and suggest contacting the site owner.',
+
+ /* Optional: load the system prompt from a plain-text URL instead of the
+ * inline string above. Leave empty to use systemPrompt. */
+ promptUrl: '',
+ promptVersion: '',
+
+ /* Prepended to the visitor's message before it is sent. */
+ questionPrefix: 'User Question:',
+
+ /* Quick-reply buttons: [label, message sent when clicked] */
+ chips: [
+ ['What do you offer?', 'What do you offer?'],
+ ['Contact', 'How can I contact you?']
+ ]
+};
diff --git a/src/widget.html b/src/widget.html
new file mode 100644
index 0000000..f0ed5ca
--- /dev/null
+++ b/src/widget.html
@@ -0,0 +1,601 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<title>Chat Widget</title>
+<!--
+ WebX11 Chat Widget 1.0.0
+ Single-file, dependency-free chat widget (streaming, chips, mobile fullscreen).
+
+ Configuration lives in config.js (copy config.example.js and edit it).
+ No API key belongs in this file or in config.js: point `endpoint` at your own
+ gateway which adds the key server-side.
+-->
+<style>
+ /* ------------------------------------------------------------------
+ Widget styles. Everything is scoped to #wx-chat-widget so the
+ widget can be pasted into an existing page without leaking styles.
+ ------------------------------------------------------------------ */
+ #wx-chat-widget {
+ --wx-accent: #00e5a0;
+ --wx-accent-ink: #07070d;
+ --wx-accent-dim: rgba(0, 229, 160, 0.12);
+ --wx-accent-glow: rgba(0, 229, 160, 0.30);
+ --wx-bg: #0b0b14;
+ --wx-bg-alt: #101020;
+ --wx-card: #151524;
+ --wx-border: #23233a;
+ --wx-border-light: #32324e;
+ --wx-text: #e8e8f2;
+ --wx-text-mid: #9c9cb8;
+ --wx-text-dim: #66667f;
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 2147483000;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-size: 15px;
+ line-height: 1.55;
+ color: var(--wx-text);
+ text-align: left;
+ }
+ #wx-chat-widget[data-position="left"] { right: auto; left: 20px; }
+
+ #wx-chat-widget *,
+ #wx-chat-widget *::before,
+ #wx-chat-widget *::after { box-sizing: border-box; }
+
+ #wx-chat-button {
+ width: 60px; height: 60px; border-radius: 50%;
+ background: var(--wx-accent); color: var(--wx-accent-ink);
+ border: none; cursor: pointer;
+ box-shadow: 0 4px 20px var(--wx-accent-glow);
+ font-size: 24px; transition: transform .25s ease, box-shadow .25s ease;
+ display: flex; align-items: center; justify-content: center;
+ -webkit-tap-highlight-color: transparent;
+ position: relative; padding: 0; margin: 0;
+ }
+ #wx-chat-button:hover { transform: scale(1.08); }
+ #wx-chat-button:focus-visible { outline: 3px solid var(--wx-accent); outline-offset: 3px; }
+ #wx-chat-button svg { width: 26px; height: 26px; display: block; }
+ #wx-chat-button.has-unread::after {
+ content: ''; position: absolute; top: 2px; right: 2px;
+ width: 14px; height: 14px; border-radius: 50%;
+ background: #ff6b4a; border: 2px solid var(--wx-bg);
+ }
+
+ #wx-chat-window {
+ display: none;
+ width: 400px; height: 560px; max-height: 80vh;
+ background: var(--wx-bg);
+ border-radius: 18px;
+ box-shadow: 0 12px 60px rgba(0, 0, 0, .55);
+ border: 1px solid var(--wx-border);
+ flex-direction: column;
+ position: absolute; bottom: 75px; right: 0;
+ overflow: hidden;
+ animation: wx-pop .28s cubic-bezier(.175, .885, .32, 1.275);
+ }
+ #wx-chat-widget[data-position="left"] #wx-chat-window { right: auto; left: 0; }
+ @keyframes wx-pop {
+ from { opacity: 0; transform: scale(.92) translateY(16px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+ }
+
+ .wx-header {
+ background: var(--wx-card); color: var(--wx-text);
+ padding: 16px 18px;
+ display: flex; justify-content: space-between; align-items: center;
+ font-weight: 700; font-size: 16px; flex-shrink: 0;
+ border-bottom: 1px solid var(--wx-border);
+ }
+ .wx-header-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
+ .wx-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .wx-status {
+ width: 8px; height: 8px; border-radius: 50%;
+ display: inline-block; flex-shrink: 0; background: var(--wx-accent);
+ }
+ .wx-status.loading { background: #fbbf24; animation: wx-pulse 1.5s infinite; }
+ .wx-status.offline { background: #ef4444; }
+ @keyframes wx-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
+
+ .wx-close {
+ background: none; border: none; color: var(--wx-text-dim);
+ cursor: pointer; font-size: 24px; line-height: 1; padding: 0 4px;
+ transition: color .2s;
+ }
+ .wx-close:hover { color: var(--wx-text); }
+ .wx-close:focus-visible { outline: 2px solid var(--wx-accent); border-radius: 4px; }
+
+ .wx-conn {
+ font-size: 10px; color: var(--wx-text-dim); text-align: center;
+ padding: 5px 8px; background: var(--wx-bg-alt);
+ border-bottom: 1px solid var(--wx-border); flex-shrink: 0;
+ }
+
+ .wx-messages {
+ flex: 1; padding: 16px; overflow-y: auto;
+ display: flex; flex-direction: column; gap: 10px;
+ background: var(--wx-bg-alt);
+ -webkit-overflow-scrolling: touch;
+ }
+ .wx-msg {
+ padding: 10px 14px; border-radius: 16px; max-width: 88%;
+ font-size: 14px; overflow-wrap: anywhere; line-height: 1.5;
+ animation: wx-fade .25s ease;
+ }
+ @keyframes wx-fade { from { opacity: 0; } to { opacity: 1; } }
+ .wx-msg-user {
+ background: var(--wx-accent); color: var(--wx-accent-ink);
+ align-self: flex-end; border-bottom-right-radius: 4px;
+ }
+ .wx-msg-ai {
+ background: var(--wx-card); color: var(--wx-text);
+ align-self: flex-start; border-bottom-left-radius: 4px;
+ border: 1px solid var(--wx-border);
+ }
+ .wx-msg-ai a {
+ color: var(--wx-accent); text-decoration: underline;
+ text-underline-offset: 2px; word-break: break-all;
+ }
+ .wx-msg-ai a:hover { opacity: .8; }
+ .wx-msg-ai code {
+ background: var(--wx-bg); padding: 1px 5px; border-radius: 4px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
+ }
+ .wx-msg-error { border-color: #ef4444; }
+
+ .wx-chips {
+ display: flex; gap: 8px; flex-wrap: wrap;
+ padding: 8px 14px; flex-shrink: 0;
+ background: var(--wx-bg-alt); border-top: 1px solid var(--wx-border);
+ }
+ .wx-chips:empty { display: none; }
+ .wx-chip {
+ background: var(--wx-card); border: 1px solid var(--wx-border);
+ color: var(--wx-accent); padding: 5px 12px; border-radius: 20px;
+ font-size: 12px; cursor: pointer; transition: all .2s;
+ white-space: nowrap; -webkit-tap-highlight-color: transparent;
+ font-family: inherit;
+ }
+ .wx-chip:hover, .wx-chip:focus-visible {
+ background: var(--wx-accent-dim); border-color: var(--wx-accent);
+ outline: none;
+ }
+
+ .wx-input-area {
+ padding: 12px 14px; border-top: 1px solid var(--wx-border);
+ display: flex; gap: 8px; background: var(--wx-card); flex-shrink: 0;
+ }
+ #wx-user-input {
+ flex: 1; min-width: 0; padding: 10px 14px;
+ border: 1px solid var(--wx-border); border-radius: 22px;
+ outline: none; font-size: 14px; transition: border-color .2s;
+ -webkit-appearance: none;
+ background: var(--wx-bg); color: var(--wx-text);
+ font-family: inherit; margin: 0;
+ }
+ #wx-user-input:focus { border-color: var(--wx-accent); }
+ #wx-user-input::placeholder { color: var(--wx-text-dim); }
+
+ #wx-send {
+ background: var(--wx-accent); color: var(--wx-accent-ink);
+ border: none; padding: 10px 18px; border-radius: 22px;
+ cursor: pointer; font-weight: 600; transition: opacity .2s;
+ white-space: nowrap; font-size: 14px; font-family: inherit;
+ margin: 0;
+ }
+ #wx-send:disabled { opacity: .45; cursor: not-allowed; }
+
+ .wx-spinner {
+ display: inline-block; width: 14px; height: 14px;
+ border: 2px solid rgba(0, 0, 0, .25); border-radius: 50%;
+ border-top-color: currentColor; animation: wx-spin .8s linear infinite;
+ vertical-align: -2px;
+ }
+ @keyframes wx-spin { to { transform: rotate(360deg); } }
+
+ .wx-typing span {
+ animation: wx-blink 1.4s infinite both;
+ font-size: 15px; line-height: 1;
+ }
+ .wx-typing span:nth-child(2) { animation-delay: .2s; }
+ .wx-typing span:nth-child(3) { animation-delay: .4s; }
+ @keyframes wx-blink { 0%,80%,100% { opacity: .2; } 40% { opacity: 1; } }
+
+ @media (max-width: 520px) {
+ #wx-chat-widget { bottom: 16px; right: 16px; }
+ #wx-chat-widget[data-position="left"] { right: auto; left: 16px; }
+ #wx-chat-button { width: 56px; height: 56px; }
+ #wx-chat-window {
+ position: fixed; inset: 0;
+ width: 100%; height: 100%; max-height: 100vh;
+ border-radius: 0; border: none;
+ }
+ .wx-messages { padding: 14px; }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ #wx-chat-widget *, #wx-chat-widget *::before, #wx-chat-widget *::after {
+ animation-duration: .01ms !important;
+ transition-duration: .01ms !important;
+ }
+ }
+</style>
+</head>
+<body>
+
+<!-- This page is only a demo host. In production you copy the widget block
+ and the two script tags into your own page (see docs/USAGE.md). -->
+<main style="max-width:720px;margin:80px auto;padding:0 24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#222;line-height:1.7;">
+ <h1 style="font-size:1.8rem;margin:0 0 12px;">Chat widget demo page</h1>
+ <p style="color:#555;">The chat bubble in the bottom-right corner is the widget. Edit
+ <code>config.js</code> to change its title, colour, welcome message, model and endpoint,
+ then reload. This paragraph is not part of the widget &mdash; delete the whole
+ <code>&lt;main&gt;</code> block when you embed the widget in your own site.</p>
+</main>
+
+<!-- ══════════════ CHAT WIDGET — copy from here ══════════════ -->
+<div id="wx-chat-widget" data-position="right">
+ <div id="wx-chat-window" role="dialog" aria-label="Chat" aria-modal="false">
+ <div class="wx-header">
+ <div class="wx-header-left">
+ <span class="wx-status loading" id="wx-status-dot"></span>
+ <span class="wx-title" id="wx-title">Chat with us</span>
+ </div>
+ <button class="wx-close" id="wx-close" type="button" aria-label="Close chat">&times;</button>
+ </div>
+ <div class="wx-conn" id="wx-conn">
+ <span id="wx-conn-text">Starting&hellip;</span>
+ </div>
+ <div class="wx-messages" id="wx-messages" aria-live="polite"></div>
+ <div class="wx-chips" id="wx-chips"></div>
+ <div class="wx-input-area">
+ <input type="text" id="wx-user-input" placeholder="Type your message&hellip;" autocomplete="off" aria-label="Your message">
+ <button id="wx-send" type="button">Send</button>
+ </div>
+ </div>
+ <button id="wx-chat-button" type="button" aria-label="Open chat">
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
+ </svg>
+ </button>
+</div>
+<!-- ══════════════ CHAT WIDGET — copy to here ══════════════ -->
+
+<!-- Optional local overrides. If config.js is missing the built-in defaults
+ below apply and the widget reports "not configured" instead of calling out. -->
+<script src="config.js"></script>
+<script>
+(function () {
+ 'use strict';
+ if (window.__wxChatWidgetLoaded) return;
+ window.__wxChatWidgetLoaded = true;
+
+ // ------------------------------------------------------------------
+ // Defaults. Real values come from config.js (window.WX_WIDGET_CONFIG).
+ // Nothing here points at any vendor host and nothing here is a secret.
+ // ------------------------------------------------------------------
+ var DEFAULTS = {
+ endpoint: '', // full URL of YOUR gateway, e.g. https://chat.example.com/ollama/api/generate
+ model: 'llama3.2:latest',
+ title: 'Chat with us',
+ accentColor: '#00e5a0',
+ welcomeMessage: 'Hi! How can I help you today?',
+ inputPlaceholder: 'Type your message…',
+ systemPrompt: 'You are a helpful assistant for this website. Answer briefly and accurately using only information you are sure about.',
+ promptUrl: '', // optional: URL returning a plain-text system prompt
+ promptVersion: '', // optional cache-buster for promptUrl
+ questionPrefix: 'User Question:',
+ chips: [], // e.g. [['Pricing', 'What does it cost?']]
+ position: 'right', // 'right' or 'left'
+ statusLine: true // show "Model: …" under the header
+ };
+
+ var supplied = window.WX_WIDGET_CONFIG || {};
+ var CFG = {};
+ for (var key in DEFAULTS) {
+ if (Object.prototype.hasOwnProperty.call(DEFAULTS, key)) {
+ CFG[key] = Object.prototype.hasOwnProperty.call(supplied, key) ? supplied[key] : DEFAULTS[key];
+ }
+ }
+ if (CFG.position !== 'left') CFG.position = 'right';
+
+ // Accept either a gateway base ("https://host/ollama") or the full generate
+ // URL ("https://host/ollama/api/generate"). Anything else gets the standard
+ // Ollama generate path appended exactly once.
+ function resolveEndpoint(raw) {
+ var u = String(raw || '').trim().replace(/\/+$/, '');
+ if (!u) return '';
+ return /\/api\/generate$/.test(u) ? u : u + '/api/generate';
+ }
+ var ENDPOINT = resolveEndpoint(CFG.endpoint);
+
+ // ------------------------------------------------------------------
+ // Theme ------------------------------------------------------------
+ // ------------------------------------------------------------------
+ function clamp(n) { return Math.max(0, Math.min(255, n)); }
+ function parseHex(hex) {
+ var h = String(hex || '').replace('#', '').trim();
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
+ }
+
+ var root = document.getElementById('wx-chat-widget');
+ var rgb = parseHex(CFG.accentColor);
+ if (root && rgb) {
+ root.style.setProperty('--wx-accent', CFG.accentColor);
+ root.style.setProperty('--wx-accent-dim', 'rgba(' + rgb.join(',') + ',0.14)');
+ root.style.setProperty('--wx-accent-glow', 'rgba(' + rgb.join(',') + ',0.32)');
+ // Pick readable ink for text sitting on the accent colour.
+ var luma = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
+ root.style.setProperty('--wx-accent-ink', luma > 0.6 ? '#0a0a12' : '#ffffff');
+ }
+ if (root) root.setAttribute('data-position', CFG.position);
+
+ // ------------------------------------------------------------------
+ // Elements ---------------------------------------------------------
+ // ------------------------------------------------------------------
+ var chatBtn = document.getElementById('wx-chat-button');
+ var chatWin = document.getElementById('wx-chat-window');
+ var messages = document.getElementById('wx-messages');
+ var userInput = document.getElementById('wx-user-input');
+ var sendBtn = document.getElementById('wx-send');
+ var closeBtn = document.getElementById('wx-close');
+ var chipRow = document.getElementById('wx-chips');
+ var statusDot = document.getElementById('wx-status-dot');
+ var connText = document.getElementById('wx-conn-text');
+ var connBar = document.getElementById('wx-conn');
+
+ var isProcessing = false;
+ var systemPrompt = null;
+
+ document.getElementById('wx-title').textContent = CFG.title;
+ userInput.placeholder = CFG.inputPlaceholder;
+ if (!CFG.statusLine) connBar.style.display = 'none';
+
+ // ------------------------------------------------------------------
+ // Helpers ----------------------------------------------------------
+ // ------------------------------------------------------------------
+ function escapeHtml(str) {
+ var div = document.createElement('div');
+ div.textContent = String(str == null ? '' : str);
+ return div.innerHTML;
+ }
+
+ function formatAiText(text) {
+ var out = escapeHtml(text)
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
+ .replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>');
+
+ var LINK = /(https?:\/\/[^\s<]+)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})|(\+\d[\d\s\/-]{6,}\d)/g;
+ out = out.replace(LINK, function (match, url, email, phone) {
+ if (url) {
+ var clean = url.replace(/[.,;:!?)\]]+$/, '');
+ return '<a href="' + clean + '" target="_blank" rel="noopener noreferrer">' +
+ clean.replace(/^https?:\/\//, '') + '</a>';
+ }
+ if (email) return '<a href="mailto:' + email + '">' + email + '</a>';
+ if (phone) return '<a href="tel:' + phone.replace(/[\s\/-]/g, '') + '">' + phone + '</a>';
+ return match;
+ });
+ return out.replace(/\n/g, '<br>');
+ }
+
+ function appendMessage(html, cls) {
+ var el = document.createElement('div');
+ el.className = 'wx-msg ' + cls;
+ el.innerHTML = html;
+ messages.appendChild(el);
+ messages.scrollTop = messages.scrollHeight;
+ if (cls === 'wx-msg-ai' && chatWin.style.display !== 'flex') {
+ chatBtn.classList.add('has-unread');
+ }
+ return el;
+ }
+
+ function removeMessage(el) {
+ if (el && el.parentNode) el.parentNode.removeChild(el);
+ }
+
+ function setStatus(state, text) {
+ statusDot.classList.remove('loading', 'offline');
+ if (state) statusDot.classList.add(state);
+ if (text != null) connText.textContent = text;
+ }
+
+ // ------------------------------------------------------------------
+ // System prompt ------------------------------------------------------
+ // ------------------------------------------------------------------
+ function loadSystemPrompt() {
+ if (!CFG.promptUrl) {
+ systemPrompt = CFG.systemPrompt;
+ setStatus('', CFG.statusLine ? 'Model: ' + CFG.model : '');
+ return Promise.resolve();
+ }
+ var url = CFG.promptUrl + (CFG.promptUrl.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(CFG.promptVersion || Date.now());
+ return fetch(url, { cache: 'no-store' })
+ .then(function (res) {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.text();
+ })
+ .then(function (txt) {
+ systemPrompt = (txt || '').trim() || CFG.systemPrompt;
+ setStatus('', CFG.statusLine ? 'Model: ' + CFG.model : '');
+ })
+ .catch(function (err) {
+ console.warn('[chat-widget] promptUrl failed, using inline systemPrompt:', err.message);
+ systemPrompt = CFG.systemPrompt;
+ setStatus('offline', CFG.statusLine ? 'Model: ' + CFG.model + ' | prompt fallback' : '');
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // UI ------------------------------------------------------------------
+ // ------------------------------------------------------------------
+ function renderChips() {
+ chipRow.innerHTML = '';
+ (CFG.chips || []).forEach(function (pair) {
+ if (!pair || !pair.length) return;
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'wx-chip';
+ b.textContent = pair[0];
+ b.dataset.msg = pair[1] || pair[0];
+ chipRow.appendChild(b);
+ });
+ }
+
+ function toggleChat() {
+ var open = chatWin.style.display === 'flex';
+ if (open) {
+ chatWin.style.display = 'none';
+ document.body.style.overflow = '';
+ } else {
+ chatWin.style.display = 'flex';
+ chatBtn.classList.remove('has-unread');
+ if (window.innerWidth <= 520) document.body.style.overflow = 'hidden';
+ userInput.focus();
+ }
+ }
+
+ chatBtn.addEventListener('click', toggleChat);
+ closeBtn.addEventListener('click', function () {
+ chatWin.style.display = 'none';
+ document.body.style.overflow = '';
+ chatBtn.focus();
+ });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape' && chatWin.style.display === 'flex') {
+ chatWin.style.display = 'none';
+ document.body.style.overflow = '';
+ }
+ });
+
+ chipRow.addEventListener('click', function (e) {
+ var chip = e.target.closest('.wx-chip');
+ if (!chip || isProcessing) return;
+ userInput.value = chip.dataset.msg;
+ sendMessage();
+ });
+
+ // ------------------------------------------------------------------
+ // Send / stream ------------------------------------------------------
+ // ------------------------------------------------------------------
+ function sendMessage() {
+ if (isProcessing) return;
+ var text = userInput.value.trim();
+ if (!text) return;
+
+ if (!ENDPOINT) {
+ appendMessage('This chat is not configured yet: set <code>endpoint</code> in <code>config.js</code>.', 'wx-msg-ai wx-msg-error');
+ return;
+ }
+ if (!systemPrompt) {
+ appendMessage('Still starting up — please wait a moment…', 'wx-msg-ai');
+ return;
+ }
+
+ isProcessing = true;
+ sendBtn.disabled = true;
+ sendBtn.innerHTML = '<span class="wx-spinner"></span>';
+ userInput.value = '';
+
+ appendMessage(escapeHtml(text), 'wx-msg-user');
+ var loadingEl = appendMessage(
+ '<span class="wx-typing"><span>●</span><span>●</span><span>●</span></span>',
+ 'wx-msg-ai'
+ );
+
+ var fullPrompt = systemPrompt + '\n\n' + CFG.questionPrefix + ' ' + text;
+
+ fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: CFG.model, prompt: fullPrompt, stream: true })
+ }).then(function (res) {
+ if (!res.ok) throw new Error('API ' + res.status + ' ' + res.statusText);
+ removeMessage(loadingEl);
+
+ if (!res.body || !res.body.getReader) {
+ return res.json().then(function (data) {
+ appendMessage(formatAiText(data.response || ''), 'wx-msg-ai');
+ chipRow.innerHTML = '';
+ });
+ }
+
+ var aiEl = appendMessage('', 'wx-msg-ai');
+ var full = '';
+ var buffer = '';
+ var reader = res.body.getReader();
+ var decoder = new TextDecoder();
+
+ function paint() {
+ aiEl.innerHTML = formatAiText(full);
+ messages.scrollTop = messages.scrollHeight;
+ }
+ function consume(line) {
+ line = line.trim();
+ if (!line) return;
+ if (line.indexOf('data:') === 0) line = line.slice(5).trim();
+ if (!line || line === '[DONE]') return;
+ try {
+ var json = JSON.parse(line);
+ var piece = json.response || (json.message && json.message.content) || '';
+ if (piece) { full += piece; paint(); }
+ } catch (_) { /* keep-alive or partial line */ }
+ }
+
+ function pump() {
+ return reader.read().then(function (r) {
+ if (r.done) {
+ if (buffer.trim()) consume(buffer);
+ return;
+ }
+ buffer += decoder.decode(r.value, { stream: true });
+ var idx;
+ while ((idx = buffer.indexOf('\n')) >= 0) {
+ consume(buffer.slice(0, idx));
+ buffer = buffer.slice(idx + 1);
+ }
+ return pump();
+ });
+ }
+ return pump();
+ }).then(function () {
+ chipRow.innerHTML = '';
+ }).catch(function (err) {
+ console.error('[chat-widget]', err);
+ removeMessage(loadingEl);
+ statusDot.classList.add('offline');
+ appendMessage(
+ 'Sorry — I could not reach the chat service. ' +
+ '<br><small style="opacity:.65">' + escapeHtml(err.message) + '</small>',
+ 'wx-msg-ai wx-msg-error'
+ );
+ }).then(function () {
+ isProcessing = false;
+ sendBtn.disabled = false;
+ sendBtn.textContent = 'Send';
+ userInput.focus();
+ });
+ }
+
+ sendBtn.addEventListener('click', sendMessage);
+ userInput.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
+ });
+
+ // ------------------------------------------------------------------
+ // Init
+ // ------------------------------------------------------------------
+ renderChips();
+ appendMessage(formatAiText(CFG.welcomeMessage), 'wx-msg-ai');
+ loadSystemPrompt();
+})();
+</script>
+</body>
+</html>