summaryrefslogtreecommitdiff
path: root/src/widget.html
diff options
context:
space:
mode:
Diffstat (limited to 'src/widget.html')
-rw-r--r--src/widget.html121
1 files changed, 102 insertions, 19 deletions
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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
}
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';