@pmeweb/plugin-chat 1.2.0 → 1.3.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 +89 -17
- package/README.md +22 -3
- package/manifest.json +3 -1
- package/package.json +1 -1
- package/src/build.d.ts +22 -7
- package/src/build.js +118 -35
- package/src/chat.css +6 -11
- package/src/worker.js +1 -1
package/INSTALL.md
CHANGED
|
@@ -90,28 +90,61 @@ package's, approved once.
|
|
|
90
90
|
`design.colors`. `opener` is `t.chat.openers[path]`, else
|
|
91
91
|
the default filled with the page's title; `logo` only for `avatar: "logo"` (an `?url` import of
|
|
92
92
|
the site's logo). The panel links to `privacyHref`.
|
|
93
|
-
8. **The context file
|
|
93
|
+
8. **The context file** (since 1.3.0: what the assistant knows). Three sources, nothing else:
|
|
94
|
+
|
|
95
|
+
- **`src/chat/context.<lang>.md`**, one per language of the site (manifest `context`), WRITTEN
|
|
96
|
+
BY CLAUDE CODE AT INSTALL for the assistant, from the site's pages and dictionary (« The
|
|
97
|
+
context file » below). Installing the chat is writing this file.
|
|
98
|
+
- **The pricing source** (`public/pricing.md`, or what the site's `pricing.md` route renders):
|
|
99
|
+
prices live there only, never in the `.md`.
|
|
100
|
+
- **The collections listed in `pmeweb.json` `plugins.chat.contextCollections`**
|
|
101
|
+
(`"portfolio"`, `"realisations,services"`: names separated by commas, no space): one line per
|
|
102
|
+
published entry, its title and a summary (its `summary`, else `resume`/`description`/`about`...,
|
|
103
|
+
else the start of its body, cut at 200 characters). Never `blog` or `authors` (the package and
|
|
104
|
+
the gate refuse them). The portal publishes these entries without Claude Code, so they stay
|
|
105
|
+
current by themselves. List a collection only when it answers a visitor's question (« avez-vous
|
|
106
|
+
fait un site pour un couvreur? »).
|
|
107
|
+
|
|
108
|
+
The route, `src/pages/plugins/chat/context.json.ts`:
|
|
94
109
|
|
|
95
110
|
```ts
|
|
96
|
-
import {
|
|
97
|
-
import {
|
|
98
|
-
import
|
|
99
|
-
import
|
|
100
|
-
import
|
|
111
|
+
import { getCollection } from 'astro:content';
|
|
112
|
+
import { chatContext, contextCollections } from '@pmeweb/plugin-chat/build';
|
|
113
|
+
import site from '../../../../pmeweb.json';
|
|
114
|
+
import pricing from '../../../../public/pricing.md?raw';
|
|
115
|
+
import contextFr from '../../../chat/context.fr.md?raw';
|
|
116
|
+
import contextEn from '../../../chat/context.en.md?raw';
|
|
101
117
|
export const prerender = true;
|
|
102
|
-
export const GET = async () =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
export const GET = async () => {
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const name of contextCollections(site.plugins?.chat?.contextCollections)) {
|
|
121
|
+
for (const e of getPublished(await getCollection(name))) { // the site's own filter
|
|
122
|
+
entries.push({ collection: name, label: 'Réalisations', lang: e.data.lang, title: e.data.titre, data: e.data, body: e.body });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return new Response(chatContext({
|
|
126
|
+
langs: { fr: { business: 'Nom', context: contextFr, pricing }, en: { business: 'Name', context: contextEn, pricing } },
|
|
127
|
+
collections: entries,
|
|
128
|
+
}));
|
|
129
|
+
};
|
|
109
130
|
```
|
|
110
131
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
132
|
+
`title` is the collection's `titleField`; `summary` may be composed by the site when no single
|
|
133
|
+
field says it (pmeweb: the portfolio's industry, then its « about »). A French-only site passes
|
|
134
|
+
`fr` alone. Never read `dist/` from a route. The build fails on a missing or empty `.md`, a `.md`
|
|
135
|
+
over 16 000 characters, a blog collection, or a file over 200 KB.
|
|
136
|
+
|
|
137
|
+
**The gate.** `a11y-tools/check-plugins.mjs` (pre-push and the builder's « Publier » preflight)
|
|
138
|
+
FAILS when a language's file is missing, over 16 000 characters, contains a price, names a blog
|
|
139
|
+
collection in `contextCollections`, or when its first line's fingerprint
|
|
140
|
+
(`<!-- chat-context sources:<16 hex> -->`) is not the hash of the site's current sources: its
|
|
141
|
+
dictionaries `src/i18n/*.ts`, its pricing source (`public/pricing.md`,
|
|
142
|
+
`src/{lib,data}/{prices,pricing}.*`) and its page files under `src/pages` (the blog routes,
|
|
143
|
+
`plugins/` and `og-card/` excluded). After writing or reviewing the files, from the site:
|
|
144
|
+
`node ../../a11y-tools/chat-context.mjs --stamp`. Renewing the stamp is a deliberate act after a
|
|
145
|
+
review, never a reflex to make the gate pass: read what changed, put in the file what is new
|
|
146
|
+
information for a visitor (a section, a service, a process, a policy), then stamp.
|
|
147
|
+
|
|
115
148
|
9. **Privacy**: nothing to paste. `<PluginPrivacy>` renders the package's section (and the voip.ms
|
|
116
149
|
and photos paragraphs when those keys are set), only while `enabled` is true: it needs
|
|
117
150
|
`@pmeweb/consent` 1.1.0 or later (manifest `activeWhen`; 1.0.0 would print it switched off, and
|
|
@@ -127,3 +160,42 @@ package's, approved once.
|
|
|
127
160
|
13. Deploy (still off: nothing on the site, health `disabled`). Once the client or the operator
|
|
128
161
|
switches it on and publishes, from a real host: the bubble shows, a question gets an answer, the signed health
|
|
129
162
|
route answers `chat.ok`.
|
|
163
|
+
|
|
164
|
+
## The context file
|
|
165
|
+
|
|
166
|
+
Written for the assistant, not for a visitor; it is never published. Read every page of the site
|
|
167
|
+
(`src/pages`, the components they render, the dictionary of the language) and the legal pages,
|
|
168
|
+
then write, in the site's language and wording, answer-shaped plain facts under `## ` headings
|
|
169
|
+
(each heading becomes a section the model reads):
|
|
170
|
+
|
|
171
|
+
- the business: what it does, for whom, where, its languages; the legal name and city when a page
|
|
172
|
+
already says them;
|
|
173
|
+
- the offer: each service or product and what it includes, in the site's own words;
|
|
174
|
+
- the process and the timelines;
|
|
175
|
+
- the frequent questions the site answers (its FAQ blocks, its terms: payment, cancellation,
|
|
176
|
+
ownership), each as a fact;
|
|
177
|
+
- what the business does not do, only where the site says so (never deduced from silence);
|
|
178
|
+
- how to reach it: form address, email, phone, hours, response time.
|
|
179
|
+
|
|
180
|
+
Rules: nothing that is not already public on the site (workspace rule: never newly publish client
|
|
181
|
+
information); **no price** (the gate refuses `$` with a digit; write « voir Tarifs »); no blog
|
|
182
|
+
post; no marketing adjectives, no em-dash, OQLF typography in French (`docs/AVOIDING_AI_TELLS.md`);
|
|
183
|
+
16 000 characters at most per language (pmeweb.ca: about 10 000). Then stamp it.
|
|
184
|
+
|
|
185
|
+
**Every later site change that adds or changes information** (a section, a service, a process, a
|
|
186
|
+
policy) updates the file in the same change; the gate says when the sources moved. Blog posts
|
|
187
|
+
never go in.
|
|
188
|
+
|
|
189
|
+
## Updating from 1.2.1
|
|
190
|
+
|
|
191
|
+
1.3.0 changes what the context is built from (1.2.1 sent llms.txt, the whole dictionary and every
|
|
192
|
+
entry's full text: about 26 000 tokens per reply on pmeweb.ca, mostly interface labels).
|
|
193
|
+
`context.json` keeps format 1; pmeweb-chat needs no change.
|
|
194
|
+
|
|
195
|
+
1. Write `src/chat/context.<lang>.md` for each language (« The context file »), then
|
|
196
|
+
`node ../../a11y-tools/chat-context.mjs --stamp`.
|
|
197
|
+
2. Rewrite `src/pages/plugins/chat/context.json.ts` as in step 8: `chatContext` no longer accepts
|
|
198
|
+
`llms` or `dictionary` (the build throws naming this section). Drop the blog posts.
|
|
199
|
+
3. If a collection helps answer visitors, list it: `"contextCollections": "portfolio"` in
|
|
200
|
+
`pmeweb.json` `plugins.chat`.
|
|
201
|
+
4. `npm run build && node ../../a11y-tools/check-plugins.mjs .` until PASS.
|
package/README.md
CHANGED
|
@@ -17,6 +17,22 @@ Three pieces:
|
|
|
17
17
|
|
|
18
18
|
Install it with the `install-plugin` skill (`INSTALL.md`).
|
|
19
19
|
|
|
20
|
+
## What the assistant knows (1.3.0)
|
|
21
|
+
|
|
22
|
+
`plugins/chat/context.json` (format 1, unchanged) is built from three sources only:
|
|
23
|
+
`src/chat/context.<lang>.md`, a file Claude Code writes for the assistant from the site (the
|
|
24
|
+
business, the offer and what it includes, the process, the frequent questions, what the business
|
|
25
|
+
does not do, how to reach it; no price, nothing not already public), the site's pricing source,
|
|
26
|
+
and the collections listed in `pmeweb.json` `plugins.chat.contextCollections` as one line per
|
|
27
|
+
entry (title and a 200-character summary). Never the blog, the dictionary or llms.txt. On
|
|
28
|
+
pmeweb.ca that is about 4 500 input tokens per reply instead of 22 300 (measured 2026-11-13, 38 replies each).
|
|
29
|
+
|
|
30
|
+
The `.md` carries a fingerprint of the site's sources on its first line;
|
|
31
|
+
`a11y-tools/check-plugins.mjs` fails a push (and the builder's preflight) when the site changed
|
|
32
|
+
since the last review, until someone rereads the change, updates the file and runs
|
|
33
|
+
`node ../../a11y-tools/chat-context.mjs --stamp`. Details: `INSTALL.md`, step 8 and « The context
|
|
34
|
+
file ».
|
|
35
|
+
|
|
20
36
|
## Behaviour worth knowing
|
|
21
37
|
|
|
22
38
|
- **A demo in the preview** (manifest `preview: "test"`). Off the site's real hosts
|
|
@@ -44,9 +60,9 @@ Install it with the `install-plugin` skill (`INSTALL.md`).
|
|
|
44
60
|
(under 640 px) it fades out by itself 8 s after it appears. The same line greets the visitor in
|
|
45
61
|
the panel. The « once per visit » is a `pmw-chat-opener` flag in `sessionStorage` (a UI flag, no
|
|
46
62
|
personal information; storage blocked: once per page).
|
|
47
|
-
- **On a phone** (under 640 px, where the panel goes full screen) the bubble is a
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
- **On a phone** (under 640 px, where the panel goes full screen) the bubble is a smaller pill
|
|
64
|
+
(48 px, a 40 px face, 14 px text). « Assistant virtuel » stays VISIBLE at every width: a face,
|
|
65
|
+
a photo above all, must never pass for a person (spec 6).
|
|
50
66
|
- **Out of the footer's way** (real hosts only). While the page's last `footer` element is in view
|
|
51
67
|
(IntersectionObserver), the bubble and the opener fade out (opacity and an 8 px slide, 0.2 s,
|
|
52
68
|
then `visibility: hidden`) and the root is `inert`; they come back when it leaves. A site whose
|
|
@@ -153,6 +169,9 @@ site changes with `a11y-tools/contrast.mjs`.
|
|
|
153
169
|
says `settings: false`.
|
|
154
170
|
- The context file absent from `dist/` or not served signed: the bot falls back to the contact form
|
|
155
171
|
on every message.
|
|
172
|
+
- A fact the site states but `src/chat/context.<lang>.md` does not: the bot says it does not know.
|
|
173
|
+
The fingerprint catches changes to the dictionaries, the pricing source and `src/pages`, not to a
|
|
174
|
+
component that hardcodes text (a site's copy belongs in its dictionary).
|
|
156
175
|
|
|
157
176
|
## Avatars
|
|
158
177
|
|
package/manifest.json
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"sms": { "pattern": "^voipms$", "optional": true },
|
|
16
16
|
"photos": { "type": "boolean", "optional": true },
|
|
17
17
|
"avatar": { "pattern": "^(?:photos-[1-5]|illustration-[1-5]|sans-visage-[1-5]|logo)$" },
|
|
18
|
-
"avatarBg": { "pattern": "^#[0-9a-f]{6}$", "optional": true }
|
|
18
|
+
"avatarBg": { "pattern": "^#[0-9a-f]{6}$", "optional": true },
|
|
19
|
+
"contextCollections": { "pattern": "^[a-z][a-z0-9-]*(?:,[a-z][a-z0-9-]*)*$", "optional": true }
|
|
19
20
|
},
|
|
20
21
|
"bindings": [
|
|
21
22
|
{ "kind": "service", "binding": "CHAT", "service": "pmeweb-chat", "entrypoint": "ChatApi" },
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
},
|
|
34
35
|
"markup": { "pages": "all", "required": ["#pmw-chat", "[data-pmw-chat-opener]"] },
|
|
35
36
|
"build": ["plugins/chat/context.json"],
|
|
37
|
+
"context": { "file": "src/chat/context.{lang}.md", "maxChars": 16000 },
|
|
36
38
|
"portal": {
|
|
37
39
|
"screens": [
|
|
38
40
|
{ "key": "appearance",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmeweb/plugin-chat",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "PmeWeb chat plugin: the bubble, the lazy chat panel, the site Worker's /api/chat routes and the build-time context file. The conversations, the model and the alerts live in the pmeweb-chat service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/build.d.ts
CHANGED
|
@@ -1,30 +1,45 @@
|
|
|
1
1
|
export declare const CONTEXT_FORMAT: 1
|
|
2
2
|
export declare const MAX_CONTEXT_BYTES: number
|
|
3
|
+
/** The curated file's budget per language, stamp line excluded. */
|
|
4
|
+
export declare const MAX_CONTEXT_CHARS: number
|
|
5
|
+
export declare const SUMMARY_CHARS: number
|
|
6
|
+
export declare const SUMMARY_FIELDS: readonly string[]
|
|
7
|
+
/** blog and authors: never in the context. */
|
|
8
|
+
export declare const NEVER_COLLECTIONS: readonly string[]
|
|
9
|
+
|
|
10
|
+
type Localized = string | Partial<Record<'fr' | 'en', string>>
|
|
3
11
|
|
|
4
12
|
export interface ChatContextLang {
|
|
5
13
|
/** The business's name in this language (the bot says it; pmeweb-chat falls back to the host). */
|
|
6
14
|
business?: string
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
/** The pricing text (the source
|
|
15
|
+
/** The text of src/chat/context.<lang>.md (the stamp line is removed here). Required. */
|
|
16
|
+
context: string
|
|
17
|
+
/** The pricing text (the site's pricing source, public/pricing.md on most sites). */
|
|
10
18
|
pricing?: string
|
|
11
|
-
/** src/i18n/<lang>.ts: flattened to `key: value` lines, strings only. */
|
|
12
|
-
dictionary?: object
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export interface ChatContextEntry {
|
|
22
|
+
/** A collection listed in plugins.chat.contextCollections; never blog or authors. */
|
|
16
23
|
collection: string
|
|
24
|
+
/** The section's title (default: the collection's name). */
|
|
25
|
+
label?: Localized
|
|
17
26
|
/** Absent: the entry belongs to every language. */
|
|
18
27
|
lang?: 'fr' | 'en'
|
|
19
28
|
title: string
|
|
29
|
+
/** The entry's one line; absent: its summary-like field (SUMMARY_FIELDS), else its body. Cut at 200 characters. */
|
|
30
|
+
summary?: Localized
|
|
20
31
|
data?: Record<string, unknown>
|
|
21
32
|
body?: string
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
/** The JSON text of plugins/chat/context.json. Deterministic; throws
|
|
35
|
+
/** The JSON text of plugins/chat/context.json. Deterministic; throws on a missing or oversized curated file. */
|
|
25
36
|
export declare function chatContext(sources: {
|
|
26
37
|
langs: Partial<Record<'fr' | 'en', ChatContextLang>>
|
|
27
38
|
collections?: ChatContextEntry[]
|
|
28
39
|
}): string
|
|
29
40
|
|
|
30
|
-
|
|
41
|
+
/** `plugins.chat.contextCollections` as a list of collection names; throws on blog or authors. */
|
|
42
|
+
export declare function contextCollections(value: unknown): string[]
|
|
43
|
+
export declare function entrySummary(entry: ChatContextEntry, lang: 'fr' | 'en'): string
|
|
44
|
+
export declare function stripStamp(md: string): string
|
|
45
|
+
export declare function contextSections(md: string, fallbackTitle: string): { title: string; text: string }[]
|
package/src/build.js
CHANGED
|
@@ -1,58 +1,125 @@
|
|
|
1
1
|
// @pmeweb/plugin-chat/build: the site's context file for pmeweb-chat (manifest `build`:
|
|
2
2
|
// plugins/chat/context.json, one file for every language). The site renders it from a prerendered
|
|
3
|
-
// route
|
|
4
|
-
// collection entries); pmeweb-chat reads `langs[lang]` (src/context.ts there):
|
|
3
|
+
// route; pmeweb-chat reads `langs[lang]` (src/context.ts there):
|
|
5
4
|
//
|
|
6
5
|
// { "format": 1, "langs": { "fr": { "business": "...", "sections": [{ "title", "text" }] } } }
|
|
7
6
|
//
|
|
7
|
+
// Since 1.3.0 the sources are three, and only three (operator decision 2026-11-13):
|
|
8
|
+
// 1. the site's curated file, src/chat/context.<lang>.md (manifest `context`), written by Claude
|
|
9
|
+
// Code for the assistant and held current by check-plugins' fingerprint. Its `## ` headings
|
|
10
|
+
// become the sections; the first-line stamp comment is not sent.
|
|
11
|
+
// 2. the pricing text (the site's pricing source, public/pricing.md on most sites): prices are
|
|
12
|
+
// never copied into the curated file.
|
|
13
|
+
// 3. the entries of the collections the site lists in `plugins.chat.contextCollections`, one line
|
|
14
|
+
// each (title and a short summary, never the body). Blog posts never go in.
|
|
15
|
+
// The dictionary and llms.txt are no longer read (1.2.0 sent them whole: ~26 000 tokens on pmeweb.ca,
|
|
16
|
+
// mostly interface labels).
|
|
17
|
+
//
|
|
8
18
|
// DETERMINISTIC: same sources, same bytes. No date, no random id, no network, no model call (the
|
|
9
19
|
// builder holds no secret, and a rebuild must not spend money). Not public: the site's worker serves
|
|
10
20
|
// it only to a signed request (plugin-kit's pluginArtifacts), never to a visitor.
|
|
11
21
|
|
|
12
22
|
export const CONTEXT_FORMAT = 1
|
|
13
|
-
/**
|
|
23
|
+
/** A last guard on the whole file; the curated file has its own, smaller budget (MAX_CONTEXT_CHARS). */
|
|
14
24
|
export const MAX_CONTEXT_BYTES = 200 * 1024
|
|
25
|
+
/** The curated file's budget per language, stamp line excluded (equal to the manifest's `context.maxChars`). */
|
|
26
|
+
export const MAX_CONTEXT_CHARS = 16000
|
|
27
|
+
/** An entry's summary is cut here, at a word. */
|
|
28
|
+
export const SUMMARY_CHARS = 200
|
|
29
|
+
/** The fields an entry's summary is taken from, in this order, before its body. */
|
|
30
|
+
export const SUMMARY_FIELDS = ['summary', 'resume', 'résumé', 'description', 'excerpt', 'extrait', 'about', 'apropos', 'intro']
|
|
31
|
+
/** Collections that never go in the context, whatever the config says. */
|
|
32
|
+
export const NEVER_COLLECTIONS = ['blog', 'authors']
|
|
15
33
|
|
|
16
34
|
const LANGS = ['fr', 'en']
|
|
17
35
|
const TITLES = {
|
|
18
|
-
fr: {
|
|
19
|
-
en: {
|
|
36
|
+
fr: { pricing: 'Tarifs', colon: ' : ' },
|
|
37
|
+
en: { pricing: 'Pricing', colon: ': ' },
|
|
20
38
|
}
|
|
21
39
|
|
|
22
40
|
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
|
23
41
|
const oneLine = (v) => v.replace(/\s*\n\s*/g, ' ').trim()
|
|
24
42
|
|
|
25
|
-
/**
|
|
26
|
-
export function
|
|
43
|
+
/** The curated file's text, its leading HTML comments (the stamp) removed. */
|
|
44
|
+
export function stripStamp(md) {
|
|
45
|
+
return text(md).replace(/^(?:<!--[\s\S]*?-->\s*)+/, '').trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The curated file as sections: one per `## ` heading; what comes before the first one is a
|
|
50
|
+
* section titled by the `# ` heading (else `fallbackTitle`).
|
|
51
|
+
*/
|
|
52
|
+
export function contextSections(md, fallbackTitle) {
|
|
27
53
|
const out = []
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
let title = fallbackTitle
|
|
55
|
+
let lines = []
|
|
56
|
+
const flush = () => {
|
|
57
|
+
const t = lines.join('\n').trim()
|
|
58
|
+
if (t) out.push({ title, text: t })
|
|
59
|
+
lines = []
|
|
60
|
+
}
|
|
61
|
+
for (const line of stripStamp(md).split(/\r?\n/)) {
|
|
62
|
+
const h1 = /^#\s+(.+?)\s*$/.exec(line)
|
|
63
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line)
|
|
64
|
+
if (h1 && out.length === 0 && lines.every((l) => l.trim() === '')) { title = h1[1]; continue }
|
|
65
|
+
if (h2) { flush(); title = h2[1]; continue }
|
|
66
|
+
lines.push(line)
|
|
36
67
|
}
|
|
37
|
-
|
|
38
|
-
return out
|
|
68
|
+
flush()
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `plugins.chat.contextCollections` ("portfolio,services") as a list; refuses the blog and its authors. */
|
|
73
|
+
export function contextCollections(value) {
|
|
74
|
+
if (value === undefined || value === null || value === '') return []
|
|
75
|
+
if (typeof value !== 'string') throw new Error('chat : contextCollections est une chaîne, des noms de collections séparés par des virgules')
|
|
76
|
+
const names = value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
77
|
+
const never = names.filter((n) => NEVER_COLLECTIONS.includes(n))
|
|
78
|
+
if (never.length) throw new Error(`chat : ${never.join(', ')} n'entre jamais dans le contexte (les articles et leurs auteurs restent sur le site)`)
|
|
79
|
+
return names
|
|
39
80
|
}
|
|
40
81
|
|
|
41
|
-
/**
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
/** Markdown reduced to plain words: links to their text, images, emphasis and heading marks gone. */
|
|
83
|
+
function plain(md) {
|
|
84
|
+
return md
|
|
85
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
86
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
87
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
88
|
+
.replace(/[*_`>]+/g, '')
|
|
89
|
+
.replace(/\s+/g, ' ')
|
|
90
|
+
.trim()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Cut at a word, at most `max` characters plus the ellipsis. */
|
|
94
|
+
function cut(s, max) {
|
|
95
|
+
if (s.length <= max) return s
|
|
96
|
+
const head = s.slice(0, max)
|
|
97
|
+
const at = head.lastIndexOf(' ')
|
|
98
|
+
return `${(at > max * 0.6 ? head.slice(0, at) : head).replace(/[\s,;:.]+$/, '')}…`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A field's value in `lang`: a string, or a `{ fr, en }` object. */
|
|
102
|
+
function inLang(v, lang) {
|
|
103
|
+
if (typeof v === 'string') return v
|
|
104
|
+
if (v && typeof v === 'object' && typeof v[lang] === 'string') return v[lang]
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An entry's one-line summary: `summary`, else its first summary-like field, else its body; 200 characters at most. */
|
|
109
|
+
export function entrySummary(e, lang) {
|
|
110
|
+
let s = inLang(e.summary, lang)
|
|
111
|
+
for (const k of SUMMARY_FIELDS) {
|
|
112
|
+
if (text(s)) break
|
|
113
|
+
s = inLang(e.data?.[k], lang)
|
|
47
114
|
}
|
|
48
|
-
|
|
49
|
-
return
|
|
115
|
+
if (!text(s)) s = text(e.body)
|
|
116
|
+
return cut(plain(oneLine(s)), SUMMARY_CHARS)
|
|
50
117
|
}
|
|
51
118
|
|
|
52
119
|
/**
|
|
53
120
|
* @param {{
|
|
54
|
-
* langs: Partial<Record<'fr' | 'en', { business?: string,
|
|
55
|
-
* collections?: { collection: string, lang?: 'fr' | 'en', title: string, data?: Record<string, unknown>, body?: string }[],
|
|
121
|
+
* langs: Partial<Record<'fr' | 'en', { business?: string, context?: string, pricing?: string }>>,
|
|
122
|
+
* collections?: { collection: string, label?: string | Record<string, string>, lang?: 'fr' | 'en', title: string, summary?: string | Record<string, string>, data?: Record<string, unknown>, body?: string }[],
|
|
56
123
|
* }} sources an entry with no `lang` belongs to every language
|
|
57
124
|
* @returns {string} the JSON text of plugins/chat/context.json
|
|
58
125
|
*/
|
|
@@ -61,22 +128,38 @@ export function chatContext({ langs = {}, collections = [] } = {}) {
|
|
|
61
128
|
if (keys.length === 0 || keys.some((l) => !LANGS.includes(l))) {
|
|
62
129
|
throw new Error(`chat : une langue fr ou en au moins, et aucune autre (reçu ${keys.join(', ') || 'rien'})`)
|
|
63
130
|
}
|
|
131
|
+
for (const l of keys) {
|
|
132
|
+
const old = ['llms', 'dictionary'].filter((k) => langs[l]?.[k] !== undefined)
|
|
133
|
+
if (old.length) throw new Error(`chat : ${old.join(' et ')} ne sont plus lus depuis 1.3.0 ; passer context (src/chat/context.${l}.md), voir INSTALL.md « Updating from 1.2.1 »`)
|
|
134
|
+
}
|
|
135
|
+
for (const e of collections) {
|
|
136
|
+
if (NEVER_COLLECTIONS.includes(e.collection)) throw new Error(`chat : la collection ${e.collection} n'entre jamais dans le contexte`)
|
|
137
|
+
}
|
|
64
138
|
const out = { format: CONTEXT_FORMAT, langs: {} }
|
|
65
139
|
for (const lang of LANGS.filter((l) => keys.includes(l))) {
|
|
66
140
|
const src = langs[lang] ?? {}
|
|
67
141
|
const t = TITLES[lang]
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
142
|
+
const business = text(src.business)
|
|
143
|
+
const curated = stripStamp(src.context ?? '')
|
|
144
|
+
if (!curated) throw new Error(`chat : src/chat/context.${lang}.md absent ou vide (le contexte de l'assistant)`)
|
|
145
|
+
if (curated.length > MAX_CONTEXT_CHARS) throw new Error(`chat : src/chat/context.${lang}.md fait ${curated.length} caractères, au-delà de ${MAX_CONTEXT_CHARS}`)
|
|
146
|
+
const sections = contextSections(curated, business || (lang === 'fr' ? 'Entreprise' : 'Business'))
|
|
147
|
+
const pricing = text(src.pricing)
|
|
148
|
+
if (pricing) sections.push({ title: t.pricing, text: pricing })
|
|
149
|
+
// One section per collection, one line per entry, in the order the site gives them.
|
|
150
|
+
const groups = new Map()
|
|
73
151
|
for (const e of collections) {
|
|
74
152
|
if (e.lang !== undefined && e.lang !== lang) continue
|
|
75
|
-
|
|
153
|
+
const title = oneLine(text(e.title))
|
|
154
|
+
if (!title) continue
|
|
155
|
+
const label = inLang(e.label, lang) || e.collection
|
|
156
|
+
if (!groups.has(label)) groups.set(label, [])
|
|
157
|
+
const summary = entrySummary(e, lang)
|
|
158
|
+
groups.get(label).push(summary ? `- ${title}${t.colon}${summary}` : `- ${title}`)
|
|
76
159
|
}
|
|
77
|
-
|
|
160
|
+
for (const [label, lines] of groups) sections.push({ title: label, text: lines.join('\n') })
|
|
78
161
|
const part = {}
|
|
79
|
-
if (
|
|
162
|
+
if (business) part.business = business
|
|
80
163
|
part.sections = sections
|
|
81
164
|
out.langs[lang] = part
|
|
82
165
|
}
|
package/src/chat.css
CHANGED
|
@@ -73,19 +73,14 @@
|
|
|
73
73
|
@keyframes pmw-chat-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
74
74
|
@keyframes pmw-chat-out { to { opacity: 0; visibility: hidden; } }
|
|
75
75
|
|
|
76
|
-
/* A phone (under 640 px, where the panel goes full screen):
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
/* A phone (under 640 px, where the panel goes full screen): a SMALLER pill, never the face alone.
|
|
77
|
+
« Assistant virtuel » stays visible at every width (spec § 6: a face, a photo above all, must
|
|
78
|
+
never pass for a person; operator, 2026-11-13). The opener fades out by itself 8 s after it appears (its space is
|
|
79
79
|
kept, above the bubble, so nothing moves). */
|
|
80
80
|
@media (max-width: 639.98px) {
|
|
81
|
-
.pmw-chat__bubble {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
.pmw-chat__label {
|
|
86
|
-
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
87
|
-
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
88
|
-
}
|
|
81
|
+
.pmw-chat__bubble { min-height: 48px; gap: 8px; padding: 4px 14px 4px 4px; }
|
|
82
|
+
.pmw-chat__bubble .pmw-chat__avatar { width: 40px; height: 40px; }
|
|
83
|
+
.pmw-chat__label { font-size: 14px; }
|
|
89
84
|
.pmw-chat__teaser { animation: pmw-chat-in 0.22s ease both, pmw-chat-out 0.2s ease 8s forwards; }
|
|
90
85
|
}
|
|
91
86
|
@media (prefers-reduced-motion: reduce) {
|
package/src/worker.js
CHANGED
|
@@ -32,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
|
|
|
32
32
|
import { RPC_HEADER, RPC_VERSION } from './protocol.js'
|
|
33
33
|
|
|
34
34
|
/** This package's version, reported by the health route (a test holds it equal to package.json). */
|
|
35
|
-
export const VERSION = '1.
|
|
35
|
+
export const VERSION = '1.3.0'
|
|
36
36
|
export { RPC_VERSION }
|
|
37
37
|
|
|
38
38
|
const SERVICE = 'https://pmeweb-chat.internal'
|