diff options
| -rw-r--r-- | CHANGELOG.md | 20 | ||||
| -rw-r--r-- | LICENSE | 54 | ||||
| -rw-r--r-- | README.md | 83 | ||||
| -rw-r--r-- | VERSION | 1 | ||||
| -rw-r--r-- | docs/USAGE.md | 233 | ||||
| -rw-r--r-- | src/config.example.js | 70 | ||||
| -rw-r--r-- | src/widget.html | 601 |
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. @@ -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. @@ -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 — 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> |