diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.example.js | 5 | ||||
| -rw-r--r-- | src/widget.html | 121 |
2 files changed, 107 insertions, 19 deletions
diff --git a/src/config.example.js b/src/config.example.js index 84d2a01..b41f4fd 100644 --- a/src/config.example.js +++ b/src/config.example.js @@ -48,6 +48,11 @@ window.WX_WIDGET_CONFIG = { /* Show the "Model: …" line under the header. Set to false to hide it. */ statusLine: true, + /* Abort a request when the gateway sends nothing for this many + * milliseconds (default 60000). Raise it if your model can take minutes + * to produce the first token; the timer resets on every received chunk. */ + idleTimeoutMs: 60000, + /* The assistant's instructions. Write this for your own business. */ systemPrompt: 'You are a helpful assistant for this website. ' + diff --git a/src/widget.html b/src/widget.html index f0ed5ca..2dd5cf2 100644 --- a/src/widget.html +++ b/src/widget.html @@ -2,7 +2,7 @@ <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"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Chat Widget</title> <!-- WebX11 Chat Widget 1.0.0 @@ -145,6 +145,7 @@ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } .wx-msg-error { border-color: #ef4444; } + .wx-error-detail { opacity: .65; } .wx-chips { display: flex; gap: 8px; flex-wrap: wrap; @@ -214,6 +215,9 @@ border-radius: 0; border: none; } .wx-messages { padding: 14px; } + /* Fullscreen panel covers the viewport; the launcher must not sit on top + of the Send button. Closing the panel brings it straight back. */ + #wx-chat-widget[data-open="true"] #wx-chat-button { display: none; } } @media (prefers-reduced-motion: reduce) { @@ -256,7 +260,7 @@ <button id="wx-send" type="button">Send</button> </div> </div> - <button id="wx-chat-button" type="button" aria-label="Open chat"> + <button id="wx-chat-button" type="button" aria-label="Open chat" aria-expanded="false" aria-controls="wx-chat-window"> <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> @@ -290,7 +294,8 @@ questionPrefix: 'User Question:', chips: [], // e.g. [['Pricing', 'What does it cost?']] position: 'right', // 'right' or 'left' - statusLine: true // show "Model: …" under the header + statusLine: true, // show "Model: …" under the header + idleTimeoutMs: 60000 // abort if the gateway sends nothing for this long }; var supplied = window.WX_WIDGET_CONFIG || {}; @@ -360,9 +365,14 @@ // Helpers ---------------------------------------------------------- // ------------------------------------------------------------------ function escapeHtml(str) { - var div = document.createElement('div'); - div.textContent = String(str == null ? '' : str); - return div.innerHTML; + // Escape quotes too: link hrefs are built as HTML strings, and a model + // reply like https://x/"onclick="... would otherwise inject attributes. + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } function formatAiText(text) { @@ -452,26 +462,47 @@ function toggleChat() { var open = chatWin.style.display === 'flex'; if (open) { - chatWin.style.display = 'none'; - document.body.style.overflow = ''; + closeChat(); } else { chatWin.style.display = 'flex'; + root.setAttribute('data-open', 'true'); + chatBtn.setAttribute('aria-expanded', 'true'); chatBtn.classList.remove('has-unread'); - if (window.innerWidth <= 520) document.body.style.overflow = 'hidden'; + if (window.innerWidth <= 520) lockScroll(); userInput.focus(); } } + // The host page owns its body: remember whatever overflow it had and put it + // back, instead of assuming it was empty. + var savedBodyOverflow = null; + function lockScroll() { + if (savedBodyOverflow === null) savedBodyOverflow = document.body.style.overflow || ''; + document.body.style.overflow = 'hidden'; + } + function unlockScroll() { + if (savedBodyOverflow !== null) { + document.body.style.overflow = savedBodyOverflow; + savedBodyOverflow = null; + } + } + + function closeChat() { + chatWin.style.display = 'none'; + root.removeAttribute('data-open'); + chatBtn.setAttribute('aria-expanded', 'false'); + unlockScroll(); + } + chatBtn.addEventListener('click', toggleChat); closeBtn.addEventListener('click', function () { - chatWin.style.display = 'none'; - document.body.style.overflow = ''; + closeChat(); chatBtn.focus(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && chatWin.style.display === 'flex') { - chatWin.style.display = 'none'; - document.body.style.overflow = ''; + closeChat(); + chatBtn.focus(); } }); @@ -512,17 +543,37 @@ var fullPrompt = systemPrompt + '\n\n' + CFG.questionPrefix + ' ' + text; + // Abort the request if the gateway goes quiet. This covers the "server + // accepts the connection and never answers" case, which used to leave the + // send button disabled forever. + var idleMs = Math.max(2000, Number(CFG.idleTimeoutMs) || 60000); + var ctrl = typeof AbortController === 'function' ? new AbortController() : null; + var idleTimer = null; + var timedOut = false; + function armIdle() { + if (!ctrl) return; + clearTimeout(idleTimer); + idleTimer = setTimeout(function () { + timedOut = true; + ctrl.abort(); + }, idleMs); + } + function disarmIdle() { clearTimeout(idleTimer); } + armIdle(); + fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: CFG.model, prompt: fullPrompt, stream: true }) + body: JSON.stringify({ model: CFG.model, prompt: fullPrompt, stream: true }), + signal: ctrl ? ctrl.signal : undefined }).then(function (res) { if (!res.ok) throw new Error('API ' + res.status + ' ' + res.statusText); + statusDot.classList.remove('offline'); removeMessage(loadingEl); if (!res.body || !res.body.getReader) { return res.json().then(function (data) { - appendMessage(formatAiText(data.response || ''), 'wx-msg-ai'); + appendMessage(formatAiText(data.response || (data.message && data.message.content) || ''), 'wx-msg-ai'); chipRow.innerHTML = ''; }); } @@ -530,11 +581,14 @@ var aiEl = appendMessage('', 'wx-msg-ai'); var full = ''; var buffer = ''; + var raw = ''; + var apiError = ''; var reader = res.body.getReader(); var decoder = new TextDecoder(); function paint() { aiEl.innerHTML = formatAiText(full); + if (chatWin.style.display !== 'flex') chatBtn.classList.add('has-unread'); messages.scrollTop = messages.scrollHeight; } function consume(line) { @@ -545,17 +599,42 @@ try { var json = JSON.parse(line); var piece = json.response || (json.message && json.message.content) || ''; + if (json.error) apiError = String(json.error); 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); + if (!full) { + // Whole-body fallback: a pretty-printed (multi-line) JSON reply, + // or an SSE frame whose data was split over several lines. + var whole = raw.trim(); + if (whole) { + var cand = whole.indexOf('data:') === 0 + ? whole.replace(/^data:\s?/gm, '') + : whole; + try { + var data = JSON.parse(cand); + var fallback = data.response || (data.message && data.message.content) || ''; + if (data.error) apiError = String(data.error); + if (fallback) { full = fallback; paint(); } + } catch (_) { /* not a single JSON document */ } + } + } + if (!full) { + aiEl.className = 'wx-msg wx-msg-ai wx-msg-error'; + aiEl.textContent = apiError + ? 'Chat service error: ' + apiError + : 'The chat service returned an empty or unreadable response.'; + } return; } - buffer += decoder.decode(r.value, { stream: true }); + armIdle(); + var chunk = decoder.decode(r.value, { stream: true }); + raw += chunk; + buffer += chunk; var idx; while ((idx = buffer.indexOf('\n')) >= 0) { consume(buffer.slice(0, idx)); @@ -572,11 +651,15 @@ removeMessage(loadingEl); statusDot.classList.add('offline'); appendMessage( - 'Sorry — I could not reach the chat service. ' + - '<br><small style="opacity:.65">' + escapeHtml(err.message) + '</small>', + 'Sorry — ' + (timedOut + ? 'the chat service did not respond within ' + Math.round(idleMs / 1000) + ' s.' + : 'I could not reach the chat service.') + + '<br><small class="wx-error-detail">' + + escapeHtml(err && err.message ? err.message : '') + '</small>', 'wx-msg-ai wx-msg-error' ); }).then(function () { + disarmIdle(); isProcessing = false; sendBtn.disabled = false; sendBtn.textContent = 'Send'; |