diff options
Diffstat (limited to 'src/widget.html')
| -rw-r--r-- | src/widget.html | 601 |
1 files changed, 601 insertions, 0 deletions
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 — delete the whole + <code><main></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">×</button> + </div> + <div class="wx-conn" id="wx-conn"> + <span id="wx-conn-text">Starting…</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…" 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> |