summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-09 15:02:17 +0100
committerAgando <roduccomm@gmail.com>2026-11-09 15:02:17 +0100
commitba15e7a4bf9dc4ab6b312f40c2e0199d4e5a8255 (patch)
tree0f834eb7b76b2eefd4c61dae50231c6c1c4385e7
feat(chat-widget): shippable widget package replacing the demo as the paid download
The store sold a €149 package whose download_url was the sales demo page. This is the widget alone: 601 lines, no CDN, no operator host, no model name, config extracted to config.example.js, wx- prefixed ids so embeds cannot collide. Verified in headless Chromium against a mock gateway (streaming, chips, link detection, unconfigured state).
-rw-r--r--CHANGELOG.md20
-rw-r--r--LICENSE54
-rw-r--r--README.md83
-rw-r--r--VERSION1
-rw-r--r--docs/USAGE.md233
-rw-r--r--src/config.example.js70
-rw-r--r--src/widget.html601
7 files changed, 1062 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..41a97b2
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to the WebX11 Chat Widget are recorded here.
+
+## 1.0.0 — 2026-11-09
+
+First packaged release.
+
+- Standalone `src/widget.html`: bubble, popup/fullscreen panel, streaming
+ answers, quick-reply chips, clickable links / e-mail / phone detection,
+ XSS-safe rendering (user input escaped, model output escaped before
+ formatting), reduced-motion and keyboard/Escape support.
+- External configuration via `config.js`; every option has a documented
+ default in `src/config.example.js`.
+- `accentColor` theming with automatic readable text colour.
+- Optional system-prompt URL (`promptUrl` + `promptVersion`) with inline
+ `systemPrompt` fallback.
+- Robust NDJSON stream reader (buffers partial lines across chunks).
+- No hardcoded host, no bundled credentials, no third-party assets.
+- Docs: README, usage guide with CORS troubleshooting, commercial license.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..bdb844f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,54 @@
+WebX11 Chat Widget — Commercial License
+Version 1.0.0
+Copyright (c) 2026 WebX11. All rights reserved.
+
+This license applies to the software in this package: the widget source
+(`src/widget.html`), the configuration template (`src/config.example.js`),
+and the accompanying documentation.
+
+1. GRANT OF LICENSE
+ Upon payment of the applicable fee, you are granted a non-exclusive,
+ non-transferable, perpetual license to:
+ a) install, run and modify the widget;
+ b) use the widget on any number of websites that you own or operate,
+ including client websites that you build and maintain on their behalf;
+ c) modify the source to fit your design and content.
+
+2. RESTRICTIONS
+ You may NOT:
+ a) resell, sublicense, rent, lease or distribute the widget itself, in
+ original or modified form, as a product, template, starter kit or
+ downloadable asset, whether alone or as part of a bundle;
+ b) publish the widget source in a public repository, marketplace or
+ template library as a standalone reusable component;
+ c) remove or obscure this license file from copies you redistribute
+ internally within your own organisation;
+ d) claim authorship of the original widget.
+
+ Building a website for a client, where the widget is one component of that
+ website, is permitted under 1(b) and is not a resale under 2(a).
+
+3. THIRD-PARTY SERVICES
+ The widget contains no third-party code and bundles no credentials. You are
+ responsible for the endpoint it calls, for the model behind it, for the
+ content it returns, and for complying with the terms of whichever model
+ provider you use.
+
+4. NO WARRANTY
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING FROM,
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
+
+5. SUPPORT
+ Thirty (30) days of setup assistance are included with the Setup Package,
+ counted from the date of purchase. Support covers installation and
+ configuration of this package; it does not cover hosting, model quality,
+ or third-party services. The optional maintenance plan is governed by its
+ own terms at the point of purchase.
+
+6. TERMINATION
+ This license terminates automatically if you breach Section 2. On
+ termination you must stop using and delete all copies of the software.
+ Sections 2, 4 and 6 survive termination.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fcea453
--- /dev/null
+++ b/README.md
@@ -0,0 +1,83 @@
+# WebX11 Chat Widget 1.0.0
+
+A single-file AI chat widget you drop onto your own website. It renders a
+floating bubble, streams answers token-by-token from an Ollama-compatible
+endpoint, formats links/e-mails/phone numbers, offers quick-reply chips, and
+goes fullscreen on phones. No build step, no framework, no external CDN, no
+tracking, no cookies.
+
+## What you receive
+
+| Item | File |
+|---|---|
+| Widget source (HTML + CSS + JS in one file) | `src/widget.html` |
+| Configuration template with every option documented | `src/config.example.js` |
+| This README + license + changelog | `README.md`, `LICENSE`, `CHANGELOG.md` |
+| Setup guide, embed snippet, troubleshooting | `docs/USAGE.md` |
+| 30 days of setup help by e-mail after purchase | included with the Setup Package |
+
+You do **not** receive a hosted service, a model, or an API key. The widget is a
+front end: it calls an endpoint that you own and operate. That is deliberate —
+your conversations and your key stay on your infrastructure.
+
+## Requirements
+
+- Any static web host (or an existing site — the widget is a fragment you paste in).
+- An Ollama-compatible endpoint reachable over HTTPS that accepts
+ `POST {"model": "...", "prompt": "...", "stream": true}` and returns
+ newline-delimited JSON with a `response` field.
+- That endpoint must allow your website's origin (CORS). See
+ `docs/USAGE.md` → Troubleshooting.
+
+## Install in 3 steps
+
+1. **Copy the files.** Put `src/widget.html` and a copy of
+ `src/config.example.js` renamed to `config.js` in the same directory on
+ your server.
+2. **Edit `config.js`.** Set `endpoint` to your gateway (for example
+ `https://YOUR-HOST/ollama`), set `model`, `title`, `accentColor`,
+ `welcomeMessage` and `systemPrompt`. Never put an API key in this file.
+3. **Open or embed.** Visit the page, or paste the widget block from
+ `src/widget.html` plus the two `<script>` tags into your existing template.
+ Done.
+
+Full instructions: `docs/USAGE.md`.
+
+## Configuration at a glance
+
+| Key | Default | Purpose |
+|---|---|---|
+| `endpoint` | `''` (required) | Your gateway base URL; `/api/generate` is appended |
+| `model` | `llama3.2:latest` | Model name as your backend knows it |
+| `title` | `Chat with us` | Header text |
+| `accentColor` | `#00e5a0` | Brand colour; readable text colour is chosen automatically |
+| `welcomeMessage` | `Hi! How can I help you today?` | First message in the transcript |
+| `inputPlaceholder` | `Type your message…` | Input hint |
+| `systemPrompt` | generic assistant | The assistant's instructions |
+| `promptUrl` / `promptVersion` | `''` | Optional: load the prompt from a text URL |
+| `chips` | `[]` | Quick-reply buttons `[label, message]` |
+| `position` | `right` | `right` or `left` |
+| `statusLine` | `true` | Show the `Model: …` line |
+
+## Known limitations (be aware before you buy)
+
+- There is **no conversation history**: each message is sent with the system
+ prompt plus that one question. The model does not see earlier turns.
+- There is **no server component** in this package. You supply the endpoint;
+ the key lives there, not in the browser.
+- There is **no persistence**: nothing is written to `localStorage` or cookies,
+ so the transcript is gone on reload (and there is no cookie banner to add).
+- Markdown support is intentionally minimal: bold, italic, inline code, links,
+ line breaks.
+
+## License
+
+Commercial license — see `LICENSE`. Use it on sites you own or operate; you may
+not resell or redistribute the widget itself as a product.
+
+## Support
+
+30 days of setup help are included with the Setup Package. Reply to your
+purchase receipt with the URL where you installed the widget and the exact
+error text (browser console + network tab) — that is usually enough to diagnose
+CORS and endpoint issues in one round trip.
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..3eefcb9
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.0.0
diff --git a/docs/USAGE.md b/docs/USAGE.md
new file mode 100644
index 0000000..b71c3cd
--- /dev/null
+++ b/docs/USAGE.md
@@ -0,0 +1,233 @@
+# Using the WebX11 Chat Widget
+
+## 1. Files
+
+```
+widget.html the widget (HTML + CSS + JS, self-contained)
+config.example.js copy to config.js and edit
+```
+
+Both files must sit in the same directory, because `widget.html` loads
+`config.js` with a relative `<script src="config.js">`. If `config.js` is
+missing the widget still renders, but it will refuse to send and prints
+“not configured” in the panel.
+
+## 2. Configure
+
+```bash
+cp config.example.js config.js
+```
+
+Open `config.js` and set at least these:
+
+```js
+window.WX_WIDGET_CONFIG = {
+ endpoint: 'https://YOUR-HOST/ollama', // your gateway, /api/generate is appended
+ model: 'llama3.2:latest',
+ title: 'Chat with us',
+ accentColor: '#00e5a0',
+ welcomeMessage: 'Hi! How can I help you today?',
+ systemPrompt: 'You are a helpful assistant for …'
+};
+```
+
+Notes:
+
+- `endpoint` may be a base URL (`https://YOUR-HOST/ollama`) or the full
+ generate URL (`https://YOUR-HOST/ollama/api/generate`); the widget appends
+ `/api/generate` only when it is not already there.
+- A relative endpoint such as `/ollama` works if the widget and the gateway
+ share an origin.
+- `accentColor` accepts `#rgb` or `#rrggbb`. Bubble/button text switches
+ between dark and light automatically for contrast.
+- `chips` is an array of `[label, message]` pairs. An empty array hides the row.
+- `promptUrl` is optional. If set, the widget fetches the system prompt from
+ that plain-text URL at load, retries once, and falls back to the inline
+ `systemPrompt` on failure.
+
+### About the API key — read this once
+
+The widget is browser code. Every byte of `config.js` and `widget.html` is
+visible to any visitor, so **no key, token or password may ever be placed in
+them**. Your gateway is responsible for authentication: it holds the key in
+a server-side secret (environment variable or root-owned file) and adds it to
+the upstream request. The browser only ever talks to your gateway.
+
+## 3. Embed in an existing site
+
+`widget.html` is a working demo page. To embed, copy two blocks out of it into
+your own template:
+
+1. The `<div id="wx-chat-widget"> … </div>` block (the markup).
+2. The `<style> … </style>` block, and the `<script>` block that follows it.
+
+Then add one tag before them:
+
+```html
+<script src="/path/to/config.js"></script>
+```
+
+Order matters: `config.js` must be loaded before the widget script runs, and
+the widget script must run after the DOM nodes exist (place it at the end of
+`<body>`, or wrap your own init in `DOMContentLoaded`).
+
+Minimal page:
+
+```html
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>My site</title>
+ <!-- paste the widget <style> block here, or link it from a .css file -->
+</head>
+<body>
+ <h1>My site</h1>
+
+ <!-- paste <div id="wx-chat-widget"> … </div> here -->
+ <!-- paste the widget <script> block here -->
+
+ <script src="/path/to/config.js"></script>
+</body>
+</html>
+```
+
+The widget uses `wx-` prefixed ids and classes and all CSS is scoped to
+`#wx-chat-widget`, so it will not collide with your site's styles. It sets
+`z-index: 2147483000`; lower it in your CSS if you have a cookie banner that
+must sit above the bubble.
+
+## 4. The endpoint contract
+
+The widget sends:
+
+```
+POST <endpoint>
+Content-Type: application/json
+
+{"model":"<model>","prompt":"<systemPrompt>\n\nUser Question: <text>","stream":true}
+```
+
+and expects newline-delimited JSON (Ollama `/api/generate` format), where each
+line has a `response` string:
+
+```
+{"model":"llama3.2:latest","response":"Hello","done":false}
+{"model":"llama3.2:latest","response":" there","done":false}
+{"model":"llama3.2:latest","response":"","done":true}
+```
+
+Server-sent events (`data: {...}`) and a non-streaming JSON reply with a
+`response` field are also accepted, so most OpenAI-style shims work if they
+emit one of those shapes.
+
+## 5. Gateway examples
+
+nginx reverse proxy in front of a local Ollama:
+
+```nginx
+location /ollama/ {
+ proxy_pass http://127.0.0.1:11434/;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_buffering off; # streaming
+ proxy_read_timeout 300s;
+ # your key is added here, server side, e.g.
+ # proxy_set_header Authorization "..." ; (kept out of this doc on purpose)
+}
+```
+
+Ollama itself refuses cross-origin browser calls unless the origin is allowed:
+
+```bash
+# systemd drop-in or shell environment for the ollama service
+OLLAMA_ORIGINS="https://www.example.com,https://example.com"
+```
+
+Caddy:
+
+```caddy
+handle_path /ollama/* {
+ reverse_proxy 127.0.0.1:11434
+}
+```
+
+## 6. Troubleshooting
+
+**Nothing happens when I open the page.**
+Check the browser console. A 404 on `config.js` means the file is not next to
+`widget.html` or the path in the `<script src>` is wrong.
+
+**“This chat is not configured yet.”**
+`endpoint` is empty in `config.js`, or `config.js` failed to load.
+
+**“Sorry — I could not reach the chat service.” with `TypeError: Failed to fetch`.**
+This is almost always CORS. The widget runs on *your* domain, so the endpoint
+must return `Access-Control-Allow-Origin` for that exact origin, and must
+answer the `OPTIONS` preflight (the request carries
+`Content-Type: application/json`, which is not a “simple” request).
+
+The endpoint must allow the customer's origin — every origin that embeds the
+widget, including `http://localhost:8000` while testing:
+
+```
+Access-Control-Allow-Origin: https://www.example.com
+Access-Control-Allow-Methods: POST, OPTIONS
+Access-Control-Allow-Headers: Content-Type
+```
+
+`Access-Control-Allow-Origin: *` also works and is fine for a public widget.
+Do not combine `*` with credentials; the widget sends no credentials.
+
+Quick check from a terminal:
+
+```bash
+curl -i -X POST https://YOUR-HOST/ollama/api/generate \
+ -H 'Origin: https://www.example.com' \
+ -H 'Content-Type: application/json' \
+ -d '{"model":"llama3.2:latest","prompt":"hi","stream":false}'
+```
+
+If the response has no `Access-Control-Allow-Origin` header, the browser will
+block it no matter what the widget does.
+
+**HTTP 401 / 403.**
+The gateway is rejecting the request. The key belongs on the gateway, not in
+`config.js`. Fix the server-side secret.
+
+**HTTP 404.**
+Wrong path. Confirm the gateway exposes `/api/generate` (the widget appends it
+to whatever base you configured).
+
+**The reply appears all at once instead of streaming.**
+A proxy is buffering. In nginx set `proxy_buffering off;` for that location.
+Cloudflare and similar CDNs may also buffer unless the response is chunked.
+
+**The model name is rejected.**
+`model` must match exactly what your backend lists (`ollama list`). If you use
+a cloud/tagged model, include the tag.
+
+**Nothing is remembered between messages.**
+By design in 1.0.0 — see the limitation note in `README.md`. Each request
+contains the system prompt plus the current question only.
+
+**Styles look wrong inside my site.**
+Your page probably sets `* { box-sizing }` or global button styles that reach
+into the widget. All widget rules are scoped to `#wx-chat-widget`, but global
+rules with higher specificity can still win; add `!important` overrides
+targeting `#wx-chat-widget` rather than editing the widget source.
+
+**The bubble overlaps my cookie banner or WhatsApp button.**
+Change `position` in `config.js`, or override in your CSS:
+
+```css
+#wx-chat-widget { bottom: 96px; }
+```
+
+## 7. Support
+
+Thirty days of setup help are included with the Setup Package. When asking for
+help, include: the page URL, the browser console error, the failing request's
+status code, and the `endpoint` value with the hostname replaced by
+`YOUR-HOST` (never send a key).
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>