@pmeweb/plugin-chat 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +130 -0
- package/LICENSE-LUCIDE +19 -0
- package/README.md +131 -2
- package/avatars/illustration-1.webp +0 -0
- package/avatars/illustration-2.webp +0 -0
- package/avatars/illustration-3.webp +0 -0
- package/avatars/illustration-4.webp +0 -0
- package/avatars/illustration-5.webp +0 -0
- package/avatars/photos-1.webp +0 -0
- package/avatars/photos-2.webp +0 -0
- package/avatars/photos-3.webp +0 -0
- package/avatars/photos-4.webp +0 -0
- package/avatars/photos-5.webp +0 -0
- package/avatars/sans-visage-1.svg +4 -0
- package/avatars/sans-visage-2.svg +3 -0
- package/avatars/sans-visage-3.svg +4 -0
- package/avatars/sans-visage-4.svg +4 -0
- package/avatars/sans-visage-5.svg +4 -0
- package/dist/trigger.iife.js +1 -0
- package/dist/widget.js +2 -0
- package/manifest.json +82 -0
- package/package.json +48 -4
- package/privacy/en.json +20 -0
- package/privacy/fr.json +20 -0
- package/src/Chat.astro +110 -0
- package/src/build.d.ts +30 -0
- package/src/build.js +87 -0
- package/src/chat.css +65 -0
- package/src/colors.js +104 -0
- package/src/csp.d.ts +2 -0
- package/src/csp.js +5 -0
- package/src/faces.js +24 -0
- package/src/protocol.js +53 -0
- package/src/protocol.ts +113 -0
- package/src/strings.js +114 -0
- package/src/trigger-entry.js +3 -0
- package/src/trigger.js +78 -0
- package/src/widget.css +108 -0
- package/src/widget.js +616 -0
- package/src/worker.d.ts +62 -0
- package/src/worker.js +273 -0
package/src/Chat.astro
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
// <Chat config={plugins.chat} lang="fr" siteKey={TURNSTILE_SITE_KEY} opener={opener} logo={logoUrl} site={site} privacyHref="/confidentialite/" />
|
|
3
|
+
// Once, in Base.astro, on every page (manifest markup.pages "all"). The bubble and the opener are
|
|
4
|
+
// plain HTML and CSS until touched: the inlined trigger shows them on the site's real hosts only
|
|
5
|
+
// (they ship `hidden`), and imports the panel (dist/widget.js, its URL in data-pmw-lazy) on the
|
|
6
|
+
// first touch. A site restyles through the --pmw-chat-* variables; it never forks this file.
|
|
7
|
+
//
|
|
8
|
+
// - `opener` is THIS page's line (the site's dictionary `chat.openers[path]`, else
|
|
9
|
+
// `chat.defaultOpener` with the entry's title). No opener throws at build: every page has one.
|
|
10
|
+
// - The avatar is config.avatar (src/faces.js): a face `photos-<n>` or `illustration-<n>` shipped in
|
|
11
|
+
// this package, an icon `sans-visage-<n>` drawn inline, or `logo` (the `logo` prop, a URL). A face
|
|
12
|
+
// is a 96 px WebP imported `?url&no-inline` (a cached file, never a data: URL repeated in every
|
|
13
|
+
// page or refused by a CSP); only the chosen one is in the page. An icon is the SVG's own text,
|
|
14
|
+
// inlined at build: no request at all. `alt=""`: the visible « Assistant virtuel » names the
|
|
15
|
+
// button (spec § 6).
|
|
16
|
+
// - The button's accessible name starts with its visible text (WCAG 2.5.3).
|
|
17
|
+
// - config.photos === true (and only true) turns the panel's photo picker on.
|
|
18
|
+
// - `site` is pmeweb.json: its `design.colors` colour the panel and the sans-visage circle (src/colors.js), as one
|
|
19
|
+
// zero-specificity rule a site's own --pmw-chat-* value beats. Absent, the package defaults.
|
|
20
|
+
// - config.enabled is the client's switch (manifest activeWhen). Anything but `true` is off: the
|
|
21
|
+
// component renders NOTHING (no markup, no script, no CSS: chat.css is inlined here rather than
|
|
22
|
+
// imported, or the bundler would put it in every page), except in a « Contenu » preview build
|
|
23
|
+
// (PMEWEB_PORTAL_PREVIEW exactly '1', set by the site builder for its preview build and dev
|
|
24
|
+
// server, never for the publish preflight), where the bubble shows forced into the demo
|
|
25
|
+
// (`demo: true`: no request, whatever the host). No visitor-side flag turns it on.
|
|
26
|
+
import code from '../dist/trigger.iife.js?raw';
|
|
27
|
+
import lazy from '../dist/widget.js?url&no-inline';
|
|
28
|
+
import { langOf, stringsFor } from './strings.js';
|
|
29
|
+
import { AVATAR_PATTERN, FACES, ICONS } from './faces.js';
|
|
30
|
+
import { HEX_RE, avatarTheme, panelTheme, siteColors, themeCss } from './colors.js';
|
|
31
|
+
import chatCss from './chat.css?inline';
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
config?: Record<string, unknown>;
|
|
35
|
+
lang?: string;
|
|
36
|
+
siteKey: string;
|
|
37
|
+
opener: string;
|
|
38
|
+
logo?: string | { src: string };
|
|
39
|
+
privacyHref?: string;
|
|
40
|
+
strings?: Record<string, string>;
|
|
41
|
+
site?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
const { config = {}, lang: rawLang = 'fr', siteKey, opener, logo, privacyHref, strings = {}, site } = Astro.props;
|
|
44
|
+
const enabled = config.enabled === true;
|
|
45
|
+
const show = enabled || process.env.PMEWEB_PORTAL_PREVIEW === '1';
|
|
46
|
+
const lang = langOf(rawLang);
|
|
47
|
+
const s = stringsFor(lang, strings);
|
|
48
|
+
if (typeof opener !== 'string' || opener.trim() === '') throw new Error('chat : pas d’accroche pour cette page');
|
|
49
|
+
if (typeof siteKey !== 'string' || siteKey === '') throw new Error('chat : la clé de site Turnstile manque');
|
|
50
|
+
|
|
51
|
+
const avatar = String(config.avatar ?? '');
|
|
52
|
+
const faces = import.meta.glob('../avatars/*.webp', { query: '?url&no-inline', import: 'default' });
|
|
53
|
+
const icons = import.meta.glob('../avatars/*.svg', { query: '?raw', import: 'default', eager: true });
|
|
54
|
+
let src: string | null = null;
|
|
55
|
+
let svg: string | null = null;
|
|
56
|
+
let kind: 'face' | 'icon' | 'logo';
|
|
57
|
+
if (!new RegExp(AVATAR_PATTERN).test(avatar)) throw new Error(`chat : avatar « ${avatar} » inconnu (${[...FACES, ...ICONS, 'logo'].join(', ')})`);
|
|
58
|
+
if (FACES.includes(avatar)) {
|
|
59
|
+
kind = 'face';
|
|
60
|
+
const face = faces[`../avatars/${avatar}.webp`];
|
|
61
|
+
if (!face) throw new Error(`chat : visage ${avatar} absent du paquet`);
|
|
62
|
+
// Only when shown: switched off, the build emits no face at all.
|
|
63
|
+
src = show ? ((await face()) as string) : null;
|
|
64
|
+
} else if (ICONS.includes(avatar)) {
|
|
65
|
+
kind = 'icon';
|
|
66
|
+
const raw = icons[`../avatars/${avatar}.svg`] as string | undefined;
|
|
67
|
+
if (!raw) throw new Error(`chat : icône ${avatar} absente du paquet`);
|
|
68
|
+
// The file's own <svg> tag (xmlns, a 48 px size) becomes a decorative one sized by the CSS.
|
|
69
|
+
svg = raw.trim().replace(/<svg\b[^>]*>/, '<svg viewBox="0 0 48 48" focusable="false">');
|
|
70
|
+
} else {
|
|
71
|
+
kind = 'logo';
|
|
72
|
+
src = typeof logo === 'string' ? logo : (logo?.src ?? null);
|
|
73
|
+
if (!src) throw new Error('chat : avatar « logo » sans la prop logo');
|
|
74
|
+
}
|
|
75
|
+
const colors = siteColors(site);
|
|
76
|
+
if (config.avatarBg !== undefined && !(typeof config.avatarBg === 'string' && HEX_RE.test(config.avatarBg))) {
|
|
77
|
+
throw new Error(`chat : avatarBg « ${config.avatarBg} » invalide (#rrggbb en minuscules)`);
|
|
78
|
+
}
|
|
79
|
+
const vars: Record<string, string> = panelTheme(colors);
|
|
80
|
+
if (kind === 'icon') {
|
|
81
|
+
const t = avatarTheme(config.avatarBg as string | undefined, colors);
|
|
82
|
+
vars['--pmw-chat-avatar-bg'] = t.bg;
|
|
83
|
+
vars['--pmw-chat-avatar-fg'] = t.fg;
|
|
84
|
+
if (t.ring) vars['--pmw-chat-avatar-ring'] = t.ring;
|
|
85
|
+
}
|
|
86
|
+
const theme = themeCss(vars);
|
|
87
|
+
const json = JSON.stringify({ lang, siteKey, avatar: src, kind, privacyHref: privacyHref ?? null, strings, ...(config.photos === true ? { photos: true } : {}), ...(enabled ? {} : { demo: true }) }).replace(/</g, '\\u003c');
|
|
88
|
+
// Every `</` and `<!--` escaped, so the inlined code cannot close or confuse its <script> tag.
|
|
89
|
+
const script = code.replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
|
|
90
|
+
---
|
|
91
|
+
{show && <style is:inline set:html={chatCss} />}
|
|
92
|
+
{show && theme && <style is:inline set:html={theme} />}
|
|
93
|
+
{show && <div id="pmw-chat" class="pmw-chat" data-pmw-chat-opener={opener} data-pmw-lazy={lazy} lang={lang === 'en' ? 'en' : 'fr-CA'} hidden>
|
|
94
|
+
<div class="pmw-chat__teaser" hidden>
|
|
95
|
+
<button type="button" class="pmw-chat__teaser-text" data-pmw-chat-open>{opener}</button>
|
|
96
|
+
<button type="button" class="pmw-chat__teaser-close" data-pmw-chat-dismiss aria-label={s.dismissTeaser}>
|
|
97
|
+
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
<button type="button" class="pmw-chat__bubble" data-pmw-chat-open aria-haspopup="dialog" aria-label={s.openLabel}>
|
|
101
|
+
{svg ? <span class="pmw-chat__avatar pmw-chat__avatar--icon" aria-hidden="true" set:html={svg} /> : (
|
|
102
|
+
<span class:list={['pmw-chat__avatar', kind === 'logo' && 'pmw-chat__avatar--logo']} aria-hidden="true">
|
|
103
|
+
<img src={src} alt="" width="48" height="48" decoding="async" />
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
<span class="pmw-chat__label">{s.assistant}</span>
|
|
107
|
+
</button>
|
|
108
|
+
<script type="application/json" data-pmw-chat-config set:html={json} />
|
|
109
|
+
</div>}
|
|
110
|
+
{show && <script is:inline data-pmw-plugin="chat" set:html={script} />}
|
package/src/build.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const CONTEXT_FORMAT: 1
|
|
2
|
+
export declare const MAX_CONTEXT_BYTES: number
|
|
3
|
+
|
|
4
|
+
export interface ChatContextLang {
|
|
5
|
+
/** The business's name in this language (the bot says it; pmeweb-chat falls back to the host). */
|
|
6
|
+
business?: string
|
|
7
|
+
/** What src/pages/llms.txt.ts renders for this language. */
|
|
8
|
+
llms?: string
|
|
9
|
+
/** The pricing text (the source of public/pricing.md). */
|
|
10
|
+
pricing?: string
|
|
11
|
+
/** src/i18n/<lang>.ts: flattened to `key: value` lines, strings only. */
|
|
12
|
+
dictionary?: object
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ChatContextEntry {
|
|
16
|
+
collection: string
|
|
17
|
+
/** Absent: the entry belongs to every language. */
|
|
18
|
+
lang?: 'fr' | 'en'
|
|
19
|
+
title: string
|
|
20
|
+
data?: Record<string, unknown>
|
|
21
|
+
body?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The JSON text of plugins/chat/context.json. Deterministic; throws above 200 KB or on an empty language. */
|
|
25
|
+
export declare function chatContext(sources: {
|
|
26
|
+
langs: Partial<Record<'fr' | 'en', ChatContextLang>>
|
|
27
|
+
collections?: ChatContextEntry[]
|
|
28
|
+
}): string
|
|
29
|
+
|
|
30
|
+
export declare function flattenDictionary(dict: object, prefix?: string): string
|
package/src/build.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @pmeweb/plugin-chat/build: the site's context file for pmeweb-chat (manifest `build`:
|
|
2
|
+
// plugins/chat/context.json, one file for every language). The site renders it from a prerendered
|
|
3
|
+
// route with sources it already has (llms.txt, its pricing text, its dictionaries, its published
|
|
4
|
+
// collection entries); pmeweb-chat reads `langs[lang]` (src/context.ts there):
|
|
5
|
+
//
|
|
6
|
+
// { "format": 1, "langs": { "fr": { "business": "...", "sections": [{ "title", "text" }] } } }
|
|
7
|
+
//
|
|
8
|
+
// DETERMINISTIC: same sources, same bytes. No date, no random id, no network, no model call (the
|
|
9
|
+
// builder holds no secret, and a rebuild must not spend money). Not public: the site's worker serves
|
|
10
|
+
// it only to a signed request (plugin-kit's pluginArtifacts), never to a visitor.
|
|
11
|
+
|
|
12
|
+
export const CONTEXT_FORMAT = 1
|
|
13
|
+
/** About 50k tokens at most: past this the prompt would cost too much per message. */
|
|
14
|
+
export const MAX_CONTEXT_BYTES = 200 * 1024
|
|
15
|
+
|
|
16
|
+
const LANGS = ['fr', 'en']
|
|
17
|
+
const TITLES = {
|
|
18
|
+
fr: { llms: 'llms.txt', pricing: 'Tarifs', dictionary: 'Textes du site', colon: ' : ' },
|
|
19
|
+
en: { llms: 'llms.txt', pricing: 'Pricing', dictionary: 'Site text', colon: ': ' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
|
23
|
+
const oneLine = (v) => v.replace(/\s*\n\s*/g, ' ').trim()
|
|
24
|
+
|
|
25
|
+
/** A dictionary as `path.to.key: value` lines, strings only, in the dictionary's own order. */
|
|
26
|
+
export function flattenDictionary(dict, prefix = '') {
|
|
27
|
+
const out = []
|
|
28
|
+
const walk = (v, path) => {
|
|
29
|
+
if (typeof v === 'string') {
|
|
30
|
+
if (v.trim() !== '') out.push(`${path}: ${oneLine(v)}`)
|
|
31
|
+
} else if (Array.isArray(v)) {
|
|
32
|
+
v.forEach((x, i) => walk(x, path ? `${path}.${i}` : String(i)))
|
|
33
|
+
} else if (v && typeof v === 'object') {
|
|
34
|
+
for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
walk(dict, prefix)
|
|
38
|
+
return out.join('\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One collection entry: its string fields as `key: value` lines (the title itself left out), then its body. */
|
|
42
|
+
function entryText(e) {
|
|
43
|
+
const lines = []
|
|
44
|
+
for (const [k, v] of Object.entries(e.data ?? {})) {
|
|
45
|
+
if (typeof v !== 'string' || v.trim() === '' || v === e.title) continue
|
|
46
|
+
lines.push(`${k}: ${oneLine(v)}`)
|
|
47
|
+
}
|
|
48
|
+
const body = text(e.body)
|
|
49
|
+
return [lines.join('\n'), body].filter(Boolean).join('\n\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{
|
|
54
|
+
* langs: Partial<Record<'fr' | 'en', { business?: string, llms?: string, pricing?: string, dictionary?: object }>>,
|
|
55
|
+
* collections?: { collection: string, lang?: 'fr' | 'en', title: string, data?: Record<string, unknown>, body?: string }[],
|
|
56
|
+
* }} sources an entry with no `lang` belongs to every language
|
|
57
|
+
* @returns {string} the JSON text of plugins/chat/context.json
|
|
58
|
+
*/
|
|
59
|
+
export function chatContext({ langs = {}, collections = [] } = {}) {
|
|
60
|
+
const keys = Object.keys(langs)
|
|
61
|
+
if (keys.length === 0 || keys.some((l) => !LANGS.includes(l))) {
|
|
62
|
+
throw new Error(`chat : une langue fr ou en au moins, et aucune autre (reçu ${keys.join(', ') || 'rien'})`)
|
|
63
|
+
}
|
|
64
|
+
const out = { format: CONTEXT_FORMAT, langs: {} }
|
|
65
|
+
for (const lang of LANGS.filter((l) => keys.includes(l))) {
|
|
66
|
+
const src = langs[lang] ?? {}
|
|
67
|
+
const t = TITLES[lang]
|
|
68
|
+
const sections = []
|
|
69
|
+
const add = (title, body) => { if (body) sections.push({ title, text: body }) }
|
|
70
|
+
add(t.llms, text(src.llms))
|
|
71
|
+
add(t.pricing, text(src.pricing))
|
|
72
|
+
add(t.dictionary, src.dictionary ? flattenDictionary(src.dictionary) : '')
|
|
73
|
+
for (const e of collections) {
|
|
74
|
+
if (e.lang !== undefined && e.lang !== lang) continue
|
|
75
|
+
add(`${e.collection}${t.colon}${text(e.title)}`, entryText(e))
|
|
76
|
+
}
|
|
77
|
+
if (sections.length === 0) throw new Error(`chat : contexte ${lang} vide`)
|
|
78
|
+
const part = {}
|
|
79
|
+
if (text(src.business)) part.business = text(src.business)
|
|
80
|
+
part.sections = sections
|
|
81
|
+
out.langs[lang] = part
|
|
82
|
+
}
|
|
83
|
+
const json = JSON.stringify(out)
|
|
84
|
+
const bytes = new TextEncoder().encode(json).length
|
|
85
|
+
if (bytes > MAX_CONTEXT_BYTES) throw new Error(`chat : contexte de ${bytes} octets, au-delà de 200 Ko (le prompt serait trop long)`)
|
|
86
|
+
return json
|
|
87
|
+
}
|
package/src/chat.css
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* @pmeweb/plugin-chat: the bubble and the opener, on the page from the first paint. Structure only:
|
|
2
|
+
the --pmw-chat-* variables are NOT declared here, each use carries its default as a fallback, so
|
|
3
|
+
a site sets them anywhere (:root, or #pmw-chat) without fighting an unlayered rule. The panel's
|
|
4
|
+
own rules arrive with the lazy widget (widget.css). Default text pairs pass AA
|
|
5
|
+
(a11y-tools/contrast.mjs): #1f2328 on #ffffff 15.8:1, #ffffff on #0a1b3d 16.98:1, #4b5563 on
|
|
6
|
+
#ffffff 7.56:1. */
|
|
7
|
+
.pmw-chat[hidden] { display: none; }
|
|
8
|
+
.pmw-chat {
|
|
9
|
+
position: fixed; z-index: 80;
|
|
10
|
+
right: calc(env(safe-area-inset-right, 0px) + var(--pmw-chat-right, 16px));
|
|
11
|
+
bottom: calc(env(safe-area-inset-bottom, 0px) + var(--pmw-chat-bottom, 16px));
|
|
12
|
+
display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
|
|
13
|
+
font-family: var(--pmw-chat-font, inherit); font-size: 16px; line-height: 1.4;
|
|
14
|
+
color: var(--pmw-chat-fg, #1f2328);
|
|
15
|
+
}
|
|
16
|
+
.pmw-chat button { font: inherit; cursor: pointer; }
|
|
17
|
+
.pmw-chat button:focus-visible {
|
|
18
|
+
outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px;
|
|
19
|
+
box-shadow: 0 0 0 2px #ffffff;
|
|
20
|
+
}
|
|
21
|
+
.pmw-chat__bubble {
|
|
22
|
+
display: flex; align-items: center; gap: 10px; min-height: 56px;
|
|
23
|
+
padding: 4px 18px 4px 4px; border-radius: 999px;
|
|
24
|
+
background: var(--pmw-chat-bubble-bg, #ffffff); color: var(--pmw-chat-fg, #1f2328);
|
|
25
|
+
border: 1px solid var(--pmw-chat-border, #c9ced6);
|
|
26
|
+
box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
|
|
27
|
+
}
|
|
28
|
+
.pmw-chat__avatar {
|
|
29
|
+
flex: none; width: 48px; height: 48px; border-radius: 50%; overflow: hidden;
|
|
30
|
+
display: flex; align-items: center; justify-content: center;
|
|
31
|
+
background: var(--pmw-chat-avatar-bg, var(--pmw-chat-accent, #0a1b3d));
|
|
32
|
+
color: var(--pmw-chat-avatar-fg, var(--pmw-chat-accent-fg, #ffffff));
|
|
33
|
+
}
|
|
34
|
+
/* A sans-visage circle under 3:1 against the page: a thin ring in ink (Chat.astro sets the ring
|
|
35
|
+
variable only then, src/colors.js avatarTheme). */
|
|
36
|
+
.pmw-chat__avatar--icon { box-shadow: 0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent); }
|
|
37
|
+
.pmw-chat__avatar img { display: block; width: 48px; height: 48px; object-fit: cover; }
|
|
38
|
+
.pmw-chat__avatar--logo { background: #ffffff; border: 1px solid var(--pmw-chat-border, #c9ced6); }
|
|
39
|
+
.pmw-chat__avatar--logo img { object-fit: contain; width: 36px; height: 36px; }
|
|
40
|
+
.pmw-chat__avatar svg { display: block; width: 100%; height: 100%; }
|
|
41
|
+
.pmw-chat__label { font-size: 15px; font-weight: 600; white-space: nowrap; }
|
|
42
|
+
|
|
43
|
+
/* The opener: once per visit, after 20 s, at 30 % of the page, or on exit intent (trigger.js). */
|
|
44
|
+
.pmw-chat__teaser[hidden] { display: none; }
|
|
45
|
+
.pmw-chat__teaser {
|
|
46
|
+
position: relative; max-width: min(300px, calc(100vw - 32px));
|
|
47
|
+
padding: 12px 40px 12px 14px; border-radius: 12px;
|
|
48
|
+
background: var(--pmw-chat-bubble-bg, #ffffff); color: var(--pmw-chat-fg, #1f2328);
|
|
49
|
+
border: 1px solid var(--pmw-chat-border, #c9ced6); box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
|
|
50
|
+
animation: pmw-chat-in 0.22s ease both;
|
|
51
|
+
}
|
|
52
|
+
.pmw-chat__teaser-text { display: block; padding: 0; border: 0; background: none; color: inherit; text-align: left; font-size: 15px; }
|
|
53
|
+
.pmw-chat__teaser-close {
|
|
54
|
+
position: absolute; top: 2px; right: 2px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%;
|
|
55
|
+
display: flex; align-items: center; justify-content: center;
|
|
56
|
+
background: none; color: var(--pmw-chat-muted, #4b5563);
|
|
57
|
+
}
|
|
58
|
+
.pmw-chat__teaser-close svg { width: 16px; height: 16px; }
|
|
59
|
+
@keyframes pmw-chat-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
60
|
+
@media (prefers-reduced-motion: reduce) {
|
|
61
|
+
.pmw-chat__teaser { animation: none; }
|
|
62
|
+
}
|
|
63
|
+
/* While the panel is open the bubble and the opener step aside. */
|
|
64
|
+
.pmw-chat[data-open] .pmw-chat__bubble, .pmw-chat[data-open] .pmw-chat__teaser { display: none; }
|
|
65
|
+
@media print { .pmw-chat { display: none; } }
|
package/src/colors.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The site's palette (pmeweb.json `design.colors`, five lowercase #rrggbb: primary, accent, ink,
|
|
2
|
+
// background, surface) turned into the --pmw-chat-* variables, at build, in Chat.astro. Every pair
|
|
3
|
+
// keeps its WCAG minimum: a choice that would not is dropped and the package default stays.
|
|
4
|
+
// contrast() is a11y-tools/contrast.mjs's ratio for opaque colours (tests/colors.test.js holds them
|
|
5
|
+
// equal); this package cannot import the workspace tool, since it is published alone.
|
|
6
|
+
|
|
7
|
+
export const HEX_RE = /^#[0-9a-f]{6}$/
|
|
8
|
+
/** The sans-visage circle without avatarBg nor design.colors.accent (the panel's default navy). */
|
|
9
|
+
export const DEFAULT_AVATAR_BG = '#0a1b3d'
|
|
10
|
+
/** The dark text colour when the site declares no ink, or when its ink is too light to help. */
|
|
11
|
+
export const INK_FALLBACK = '#14181f'
|
|
12
|
+
const WHITE = '#ffffff'
|
|
13
|
+
const KEYS = ['primary', 'accent', 'ink', 'background', 'surface']
|
|
14
|
+
|
|
15
|
+
function luminance(hex) {
|
|
16
|
+
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
17
|
+
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
18
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function contrast(a, b) {
|
|
22
|
+
const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p)
|
|
23
|
+
return (x + 0.05) / (y + 0.05)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** design.colors, its valid entries only (lowercased). */
|
|
27
|
+
export function siteColors(site) {
|
|
28
|
+
const c = site?.design?.colors
|
|
29
|
+
const out = {}
|
|
30
|
+
if (!c || typeof c !== 'object') return out
|
|
31
|
+
for (const k of KEYS) {
|
|
32
|
+
const v = typeof c[k] === 'string' ? c[k].toLowerCase() : ''
|
|
33
|
+
if (HEX_RE.test(v)) out[k] = v
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The text colour for a fill: white or the site's ink, whichever contrasts more; when neither
|
|
40
|
+
* reaches `min` (a light ink on a light fill), the dark fallback if it does. Null when nothing does.
|
|
41
|
+
*/
|
|
42
|
+
export function textOn(fill, ink, min) {
|
|
43
|
+
const pick = [WHITE, ink ?? INK_FALLBACK].sort((a, b) => contrast(b, fill) - contrast(a, fill))[0]
|
|
44
|
+
if (contrast(pick, fill) >= min) return pick
|
|
45
|
+
return contrast(INK_FALLBACK, fill) >= min ? INK_FALLBACK : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The sans-visage circle: `avatarBg`, else the accent, else the package default. The glyph keeps
|
|
50
|
+
* 3:1 (a graphic). A circle under 3:1 against the page (or the bubble's surface) gets a thin ring
|
|
51
|
+
* in ink, so its edge still shows; never a text notice.
|
|
52
|
+
*/
|
|
53
|
+
export function avatarTheme(avatarBg, colors = {}) {
|
|
54
|
+
const bg = avatarBg ?? colors.accent ?? DEFAULT_AVATAR_BG
|
|
55
|
+
const fg = textOn(bg, colors.ink, 3) ?? [WHITE, INK_FALLBACK].sort((a, b) => contrast(b, bg) - contrast(a, bg))[0]
|
|
56
|
+
const behind = [colors.background ?? WHITE, colors.surface].filter(Boolean)
|
|
57
|
+
const ring = behind.some((b) => contrast(bg, b) < 3) ? (colors.ink ?? INK_FALLBACK) : null
|
|
58
|
+
return { bg, fg, ring }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ERRORS = ['#b42318', '#ffb4ab']
|
|
62
|
+
|
|
63
|
+
/** `fg` laid over `bg` at `alpha`, as an opaque #rrggbb. */
|
|
64
|
+
function mix(fg, bg, alpha) {
|
|
65
|
+
const ch = (h, i) => parseInt(h.slice(i, i + 2), 16)
|
|
66
|
+
return `#${[1, 3, 5].map((i) => Math.round(ch(fg, i) * alpha + ch(bg, i) * (1 - alpha)).toString(16).padStart(2, '0')).join('')}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The panel (and the bubble beside it) in the site's colours, two groups each all-or-nothing:
|
|
71
|
+
* - primary: the header, the visitor's bubbles and the send button, text white or ink at 4.5:1;
|
|
72
|
+
* - surface: the panel and the bubble in `surface`, text in `ink` at 4.5:1, the bot's bubbles in
|
|
73
|
+
* `background`, and the colours that sit on them (muted, link, focus, error) re-derived so each
|
|
74
|
+
* keeps its minimum. A group whose pair fails sets nothing: the package defaults stay.
|
|
75
|
+
*/
|
|
76
|
+
export function panelTheme(colors = {}) {
|
|
77
|
+
const v = {}
|
|
78
|
+
const { primary, ink, surface, background } = colors
|
|
79
|
+
if (primary) {
|
|
80
|
+
const fg = textOn(primary, ink, 4.5)
|
|
81
|
+
if (fg) Object.assign(v, { '--pmw-chat-accent': primary, '--pmw-chat-accent-fg': fg })
|
|
82
|
+
}
|
|
83
|
+
if (surface && ink && contrast(ink, surface) >= 4.5) {
|
|
84
|
+
const muted = mix(ink, surface, 0.75)
|
|
85
|
+
const page = background ?? WHITE
|
|
86
|
+
Object.assign(v, {
|
|
87
|
+
'--pmw-chat-panel-bg': surface,
|
|
88
|
+
'--pmw-chat-bubble-bg': surface,
|
|
89
|
+
'--pmw-chat-fg': ink,
|
|
90
|
+
'--pmw-chat-other-bg': background && contrast(ink, background) >= 4.5 ? background : surface,
|
|
91
|
+
'--pmw-chat-muted': contrast(muted, surface) >= 4.5 ? muted : ink,
|
|
92
|
+
'--pmw-chat-link': primary && contrast(primary, surface) >= 4.5 ? primary : ink,
|
|
93
|
+
'--pmw-chat-focus': contrast(ink, page) >= 3 ? ink : INK_FALLBACK,
|
|
94
|
+
'--pmw-chat-error': ERRORS.find((e) => contrast(e, surface) >= 4.5) ?? ink,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
return v
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One rule at zero specificity: a site's own --pmw-chat-* value, anywhere, still wins. */
|
|
101
|
+
export function themeCss(vars) {
|
|
102
|
+
const decl = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';')
|
|
103
|
+
return decl ? `:where(:root){${decl}}` : ''
|
|
104
|
+
}
|
package/src/csp.d.ts
ADDED
package/src/csp.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The chat posts through Turnstile: its widget's script and frame. The socket and /api/chat/start
|
|
2
|
+
// are same-origin, which 'self' in connect-src already covers.
|
|
3
|
+
export function cspHosts() {
|
|
4
|
+
return { 'script-src': ['https://challenges.cloudflare.com'], 'frame-src': ['https://challenges.cloudflare.com'] }
|
|
5
|
+
}
|
package/src/faces.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The avatars this package ships (avatars/), the one list the component, the manifest's `avatar`
|
|
2
|
+
// pattern and the tests read. A config names one as `<category>-<n>`: `photos-3`,
|
|
3
|
+
// `illustration-2` (a 96 px WebP face), `sans-visage-1` (an SVG drawn in currentColor on the
|
|
4
|
+
// `avatarBg` colour), or `logo` (the site's own, through the component's `logo` prop).
|
|
5
|
+
//
|
|
6
|
+
// ADDING A FACE IS NOT A BREAKING CHANGE: drop the file in avatars/, raise its category's count
|
|
7
|
+
// here (or add a category, `3d` when its faces exist), and copy the new AVATAR_PATTERN into
|
|
8
|
+
// manifest.json (tests/manifest.test.js holds the two equal). Every value a site already uses stays
|
|
9
|
+
// valid. Never renumber or remove a face a site may have chosen.
|
|
10
|
+
|
|
11
|
+
/** Faces per category, numbered from 1. */
|
|
12
|
+
export const FACE_COUNTS = { photos: 5, illustration: 5 }
|
|
13
|
+
export const ICON_COUNT = 5
|
|
14
|
+
|
|
15
|
+
const range = (n) => Array.from({ length: n }, (_, i) => i + 1)
|
|
16
|
+
export const FACES = Object.entries(FACE_COUNTS).flatMap(([c, n]) => range(n).map((i) => `${c}-${i}`))
|
|
17
|
+
export const ICONS = range(ICON_COUNT).map((i) => `sans-visage-${i}`)
|
|
18
|
+
|
|
19
|
+
const upTo = (n) => (n < 10 ? `[1-${n}]` : `(?:[1-9]|1[0-${n - 10}])`)
|
|
20
|
+
export const AVATAR_PATTERN = `^(?:${[
|
|
21
|
+
...Object.entries(FACE_COUNTS).map(([c, n]) => `${c}-${upTo(n)}`),
|
|
22
|
+
`sans-visage-${upTo(ICON_COUNT)}`,
|
|
23
|
+
'logo',
|
|
24
|
+
].join('|')})$`
|
package/src/protocol.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// GENERATED by build.mjs from src/protocol.ts (a verbatim copy of pmeweb-chat/src/protocol.ts). Do not edit.
|
|
2
|
+
const RPC_HEADER = "x-pmw-rpc";
|
|
3
|
+
const RPC_VERSION = 1;
|
|
4
|
+
const MAX_TEXT = 2e3;
|
|
5
|
+
const CLOSE = { unauthorized: 4401, expired: 4410, rpc: 4426, deleted: 4404 };
|
|
6
|
+
const ACTOR_HEADER = "x-pmw-actor";
|
|
7
|
+
function encodeActor(actor) {
|
|
8
|
+
return JSON.stringify(actor).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
9
|
+
}
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
function isUuid(v) {
|
|
12
|
+
return typeof v === "string" && UUID.test(v);
|
|
13
|
+
}
|
|
14
|
+
function parseClient(raw) {
|
|
15
|
+
let v;
|
|
16
|
+
try {
|
|
17
|
+
v = JSON.parse(raw);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return null;
|
|
22
|
+
const o = v;
|
|
23
|
+
switch (o.type) {
|
|
24
|
+
case "hello": {
|
|
25
|
+
const since = o.since;
|
|
26
|
+
if (typeof since !== "number" || !Number.isSafeInteger(since) || since < 0) return null;
|
|
27
|
+
return { type: "hello", since };
|
|
28
|
+
}
|
|
29
|
+
case "message": {
|
|
30
|
+
const { id, text } = o;
|
|
31
|
+
if (!isUuid(id)) return null;
|
|
32
|
+
if (typeof text !== "string" || text.length > MAX_TEXT || text.trim() === "") return null;
|
|
33
|
+
return { type: "message", id, text };
|
|
34
|
+
}
|
|
35
|
+
case "typing":
|
|
36
|
+
case "takeover":
|
|
37
|
+
case "handback":
|
|
38
|
+
case "close":
|
|
39
|
+
return { type: o.type };
|
|
40
|
+
default:
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export {
|
|
45
|
+
ACTOR_HEADER,
|
|
46
|
+
CLOSE,
|
|
47
|
+
MAX_TEXT,
|
|
48
|
+
RPC_HEADER,
|
|
49
|
+
RPC_VERSION,
|
|
50
|
+
encodeActor,
|
|
51
|
+
isUuid,
|
|
52
|
+
parseClient
|
|
53
|
+
};
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The one message format of every chat socket: the visitor's widget, the owner's public page, the
|
|
2
|
+
// portal's tabs (spec § 5). Versioned by RPC_VERSION (plugin-rpc.json); a breaking change is a new
|
|
3
|
+
// version the service accepts alongside the old one until every site is updated.
|
|
4
|
+
//
|
|
5
|
+
// This file is copied VERBATIM into the plugin package and the portal, and a test in each compares
|
|
6
|
+
// the copy byte for byte with this one. So it imports nothing: what the service accepts (read from
|
|
7
|
+
// plugin-rpc.json) lives in src/rpc.ts, which only this repo has.
|
|
8
|
+
export const RPC_HEADER = 'x-pmw-rpc';
|
|
9
|
+
export const RPC_VERSION = 1;
|
|
10
|
+
export const MAX_TEXT = 2000;
|
|
11
|
+
|
|
12
|
+
export type Role = 'visitor' | 'bot' | 'owner';
|
|
13
|
+
export type Mode = 'bot' | 'human';
|
|
14
|
+
|
|
15
|
+
/** Client -> server. `id` is a client-made UUID: a message re-sent after a reconnect is stored once. */
|
|
16
|
+
export type ClientMsg =
|
|
17
|
+
| { type: 'hello'; since: number }
|
|
18
|
+
| { type: 'message'; id: string; text: string }
|
|
19
|
+
| { type: 'typing' }
|
|
20
|
+
| { type: 'takeover' }
|
|
21
|
+
| { type: 'handback' }
|
|
22
|
+
| { type: 'close' };
|
|
23
|
+
|
|
24
|
+
/** Server -> client. `delta` streams a bot answer; the final `message` replaces it. */
|
|
25
|
+
export type ServerMsg =
|
|
26
|
+
| { type: 'history'; messages: StoredMsg[]; mode: Mode; ownerName: string | null; closed: boolean }
|
|
27
|
+
| { type: 'message'; msg: StoredMsg }
|
|
28
|
+
| { type: 'delta'; id: string; text: string }
|
|
29
|
+
| { type: 'typing'; role: Role }
|
|
30
|
+
| { type: 'status'; mode: Mode; ownerName: string | null; closed: boolean }
|
|
31
|
+
| { type: 'fallback'; reason: 'cap' | 'model_down' }
|
|
32
|
+
/** To the visitor's sockets only, once per conversation: the widget fires pmw.event('chat-lead'). */
|
|
33
|
+
| { type: 'lead' }
|
|
34
|
+
| { type: 'error'; code: string };
|
|
35
|
+
|
|
36
|
+
export interface StoredMsg { seq: number; id: string; role: Role; text: string; at: number; by?: string; photos?: string[] }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Close codes (4000-4999 are the application's). A refusal is always one of these on an accepted
|
|
40
|
+
* socket, never an HTTP status: a browser's WebSocket sees only « non-101 » and would reconnect
|
|
41
|
+
* forever (spike task 1). A 44xx close means « do not reconnect »; 1006 means « reconnect ».
|
|
42
|
+
*/
|
|
43
|
+
export const CLOSE = { unauthorized: 4401, expired: 4410, rpc: 4426, deleted: 4404 } as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Who is on a socket, and what they may do. Set ONLY by a trusted entrypoint, never by the client:
|
|
47
|
+
* ChatApi sets `visitor` after checking the visitor key, the portal sets `owner` (public link) or
|
|
48
|
+
* `staff` (portal session) after `resolveConversationAccess`. It rides to the Durable Object in
|
|
49
|
+
* ACTOR_HEADER and is stored on the socket, so every frame is judged by it.
|
|
50
|
+
*/
|
|
51
|
+
export const ACTOR_HEADER = 'x-pmw-actor';
|
|
52
|
+
export type Scope = 'reply' | 'takeover' | 'close';
|
|
53
|
+
export interface Actor {
|
|
54
|
+
kind: 'visitor' | 'owner' | 'staff';
|
|
55
|
+
/** Shown to the visitor when this person takes over (owner, staff). */
|
|
56
|
+
name?: string;
|
|
57
|
+
/** SHA-256 hex of the public link's token: revoking the link closes exactly these sockets. */
|
|
58
|
+
tokenHash?: string;
|
|
59
|
+
/** SHA-256 hex of the visitor key, compared with the one stored at start (visitor only). */
|
|
60
|
+
keyHash?: string;
|
|
61
|
+
scope: Scope[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The actor as an ASCII header value. Owner names carry accents (« Éric »); a header must be ASCII
|
|
66
|
+
* in the Fetch spec and workerd only tolerates UTF-8 as a quirk, so every non-ASCII character is
|
|
67
|
+
* written as a JSON \u escape, which JSON.parse reads back unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export function encodeActor(actor: Actor): string {
|
|
70
|
+
return JSON.stringify(actor).replace(/[\u0080-\uffff]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
74
|
+
|
|
75
|
+
export function isUuid(v: unknown): v is string {
|
|
76
|
+
return typeof v === 'string' && UUID.test(v);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Parses one frame from a client. Anything unexpected is null, and the caller answers
|
|
81
|
+
* `error bad_message`: a frame is never half-trusted. Only the declared fields are copied, so an
|
|
82
|
+
* extra field (a `role`, a `by`) can never ride through to storage or to the other sockets.
|
|
83
|
+
*/
|
|
84
|
+
export function parseClient(raw: string): ClientMsg | null {
|
|
85
|
+
let v: unknown;
|
|
86
|
+
try {
|
|
87
|
+
v = JSON.parse(raw);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) return null;
|
|
92
|
+
const o = v as Record<string, unknown>;
|
|
93
|
+
switch (o.type) {
|
|
94
|
+
case 'hello': {
|
|
95
|
+
const since = o.since;
|
|
96
|
+
if (typeof since !== 'number' || !Number.isSafeInteger(since) || since < 0) return null;
|
|
97
|
+
return { type: 'hello', since };
|
|
98
|
+
}
|
|
99
|
+
case 'message': {
|
|
100
|
+
const { id, text } = o;
|
|
101
|
+
if (!isUuid(id)) return null;
|
|
102
|
+
if (typeof text !== 'string' || text.length > MAX_TEXT || text.trim() === '') return null;
|
|
103
|
+
return { type: 'message', id, text };
|
|
104
|
+
}
|
|
105
|
+
case 'typing':
|
|
106
|
+
case 'takeover':
|
|
107
|
+
case 'handback':
|
|
108
|
+
case 'close':
|
|
109
|
+
return { type: o.type };
|
|
110
|
+
default:
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|