Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f66bc74
Merge pull request #475 from codercops/production
anurag629 Oct 30, 2026
d2494da
feat(widget): support CSP nonces for the widget's styles (#473)
piyusshhjangid Oct 30, 2026
2f9b9dc
feat(widget): wait for cookie consent before using storage (#468)
Saifjafri212213 Oct 30, 2026
991839f
feat(widget): userToken can be a function that returns a fresh token …
Saifjafri212213 Oct 30, 2026
c0bb930
fix(cli): search the index set in ingest.index (#480)
prakashNalla Oct 30, 2026
ba661de
ci: a one-off workflow for the first publish of cli, ingest and the s…
anurag629 Oct 30, 2026
f462a1d
feat(website): replace the theme dropdown with a toggle button (#484)
anurag2787 Oct 30, 2026
dd26493
ci: remove the one-off first publish workflow (#487)
anurag629 Oct 30, 2026
5309e65
feat(widget): reuse the script tag's CSP nonce when init() has none (…
Sreehita-here Oct 30, 2026
42d5afb
feat(website): move the theme toggle next to search on mobile (#489)
anurag2787 Oct 30, 2026
58a2b6e
docs(widget): showOnce follows persistence, not the session (#492)
prakashNalla Oct 30, 2026
6696a21
feat(widget): setUser({ token }) takes a token function, like userTok…
Saifjafri212213 Oct 30, 2026
0348e0e
docs: every server example starts on 1.0, and docs:check makes sure (…
anurag629 Oct 30, 2026
3c0f13c
test(core): allow slower concurrent append checks (#497)
Sreehita-here Oct 30, 2026
db84790
docs: rebuild the docs bot index for the 1.0 snippets, re-record its …
anurag629 Oct 30, 2026
05cf305
feat(widget): a Stop button while a reply streams (#498)
Saifjafri212213 Oct 30, 2026
a3ed530
feat(widget): keep a message sent while offline, and send it when the…
Saifjafri212213 Oct 31, 2026
fc22793
ci: room in the widget budgets for the reply actions and the header m…
anurag629 Oct 31, 2026
aa85ebd
fix(widget): keep the numbers of numbered lists in replies (#509)
prakashNalla Oct 31, 2026
c0b5a2c
docs(core): update the default models in the README (#510)
prakashNalla Oct 31, 2026
b591574
docs: READMEs show 1.0 code, and docs:check type-checks them (#511)
anurag629 Oct 31, 2026
e699c3d
feat(widget): add header menu with conversation and privacy options (…
anurag2787 Oct 31, 2026
7a2bcf3
chore: leave rc mode for the 1.0.0 release (#513)
anurag629 Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/core-concurrency-test-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/core": patch
---

Give the shared concurrent-append conformance test more time to complete on slower test environments.
5 changes: 5 additions & 0 deletions .changeset/examples-start-on-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@chatcops/server': patch
---

The Express example mounts the handler with `app.use('/chat', ...)`, so the widget's `/chat/session` request reaches it, and its test command gets a session token first. The Vercel example says to add `api/chat/session.ts`.
5 changes: 5 additions & 0 deletions .changeset/kind-csp-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Apply CSP nonces to the widget's injected styles.
5 changes: 5 additions & 0 deletions .changeset/ol-keep-numbers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Numbered lists in replies keep their numbers. A list that starts after 1, including each step of a list split by blank lines, now renders with `start` instead of restarting at 1.
2 changes: 1 addition & 1 deletion .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"mode": "pre",
"mode": "exit",
"tag": "rc",
"initialVersions": {
"@chatcops/cli": "0.1.0",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/search-config-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/cli": patch
---

`chatcops search` now uses `ingest.index` from the config when `--index` isn't passed, the same index `chatcops ingest` builds.
5 changes: 5 additions & 0 deletions .changeset/widget-consent-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

`waitForConsent` holds the chat behind a notice until `ChatCops.setConsent(true)`. While it waits the widget uses no storage and calls no API; `setConsent(false)` closes the chat and removes what it saved. The notice has a button that calls `onConsentRequest`, and the script tag takes `data-wait-for-consent`.
5 changes: 5 additions & 0 deletions .changeset/widget-header-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

Add a header "More options" menu (`⋯`) with "New conversation", "Download transcript" and an optional "Privacy" link configured via `privacyUrl` (or `data-privacy-url`).
5 changes: 5 additions & 0 deletions .changeset/widget-manual-csp-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Reuse a script tag's CSP nonce when manually initializing the widget.
5 changes: 5 additions & 0 deletions .changeset/widget-offline-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

A message sent while the browser is offline is shown as waiting ("Will send when you're back online", the new `messageWaiting` string) instead of failing. The widget sends the waiting messages in order, one at a time, when the `online` event fires. They are never saved, and a reload or a new conversation drops them.
5 changes: 5 additions & 0 deletions .changeset/widget-setuser-token-function.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

`ChatCops.setUser({ token })` takes a function (sync or async) that returns a fresh token, the same as `userToken`. Passing the same function again keeps the chat; a different function, a string or no token starts a new one.
5 changes: 5 additions & 0 deletions .changeset/widget-stop-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

While a reply streams, the send button becomes a Stop button. Clicking it ends the request and keeps what has streamed as a finished reply, with Regenerate and no error. The button's name is the new `stopButton` string, with an English default.
5 changes: 5 additions & 0 deletions .changeset/widget-user-token-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

`userToken` can be a function (sync or async) that returns a fresh signed token. The widget calls it once each time it asks the server for a session, not per message. If it throws, rejects or takes over 5 seconds, the session starts without the visitor and a warning is logged. A string still works as before.
12 changes: 9 additions & 3 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
{
"name": "widget, chat panel (chatcops.panel.js, loaded on open)",
"path": "packages/widget/dist/chatcops.panel.js",
"limit": "18 KB",
"limit": "19 KB",
"gzip": true
},
{
Expand All @@ -17,16 +17,22 @@
"limit": "2 KB",
"gzip": true
},
{
"name": "widget, header menu (chunks/menu-*.js, loaded on first open)",
"path": "packages/widget/dist/chunks/menu-*.js",
"limit": "2 KB",
"gzip": true
},
{
"name": "widget, single file (chatcops.full.min.js)",
"path": "packages/widget/dist/chatcops.full.min.js",
"limit": "20 KB",
"limit": "22 KB",
"gzip": true
},
{
"name": "widget, ESM (unminified: sites bundle and minify it, so this only catches runaway growth)",
"path": "packages/widget/dist/chatcops.esm.js",
"limit": "30 KB",
"limit": "32 KB",
"gzip": true
}
]
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ ANTHROPIC_API_KEY=sk-... npx tsx src/examples/express-server.ts

`npx` downloads `tsx` the first time.

The widget has a size budget, gzipped: 5 kB for the script tag loader (`dist/chatcops.min.js`), 18 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 20 kB for the single-file build (`dist/chatcops.full.min.js`) and 30 kB for the unminified ESM build. CI checks them with `pnpm size` after a build (limits in `.size-limit.json`). To save bytes, the minifier renames the private class members listed in `packages/widget/mangle.ts`. A name can go on that list only if the code touches it through `this` and nowhere else, which `tests/mangle.test.ts` checks. A config field, a request body key or a DOM property renamed there would break only the CDN build.
The widget has a size budget, gzipped: 5 kB for the script tag loader (`dist/chatcops.min.js`), 19 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 22 kB for the single-file build (`dist/chatcops.full.min.js`) and 32 kB for the unminified ESM build. CI checks them with `pnpm size` after a build (limits in `.size-limit.json`). To save bytes, the minifier renames the private class members listed in `packages/widget/mangle.ts`. A name can go on that list only if the code touches it through `this` and nowhere else, which `tests/mangle.test.ts` checks. A config field, a request body key or a DOM property renamed there would break only the CDN build.

### Browser tests

Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ app.use(express.json());
app.use('/chat', chatcopsMiddleware({
provider: {
type: 'claude',
apiKey: process.env.ANTHROPIC_API_KEY,
apiKey: process.env.ANTHROPIC_API_KEY!,
},
systemPrompt: 'You are a helpful assistant for our website.',
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET], // openssl rand -base64 32
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!], // openssl rand -base64 32
}));

app.listen(3001);
Expand All @@ -52,7 +52,7 @@ That's it. Your site now has an AI chat widget.
## Features

- **Multi-provider** - Claude, OpenAI, Gemini. Switch with one config change.
- **Zero-dependency widget** - Shadow DOM isolated, ~13KB gzipped.
- **Zero-dependency widget** - Shadow DOM isolated. The script tag loads a launcher under 5 kB gzipped, and the chat itself loads when it's opened.
- **Streaming responses** - Real-time SSE streaming from all providers.
- **Lead capture** - Built-in tool for capturing contact info, with webhook support.
- **Knowledge base** - Feed text chunks or FAQ pairs for context-aware responses.
Expand Down Expand Up @@ -104,7 +104,7 @@ ChatCops.init({
delay: 3000,
},
locale: 'en',
persistHistory: true,
persistence: 'local', // the default; 'session' keeps it for the tab, 'none' saves nothing
onMessage: (msg) => console.log('New message:', msg),
});
```
Expand All @@ -117,11 +117,14 @@ ChatCops.init({
import { chatcopsVercelHandler } from '@chatcops/server/vercel';

export default chatcopsVercelHandler({
provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY },
provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY! },
systemPrompt: 'You are a helpful assistant.',
sessionSecrets: [process.env.CHATCOPS_SESSION_SECRET!],
});
```

Add `api/chat/session.ts` with `export { default } from '../chat';` so the widget can get its session token there.

### Cloudflare Workers

```typescript
Expand All @@ -132,6 +135,7 @@ function getHandler(env: Env) {
handler ??= chatcopsCloudflareHandler({
provider: { type: 'openai', apiKey: env.OPENAI_API_KEY },
systemPrompt: 'You are a helpful assistant.',
sessionSecrets: [env.CHATCOPS_SESSION_SECRET],
});
return handler;
}
Expand Down
23 changes: 19 additions & 4 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ declare const _default: {
setUser(user: {
email?: string;
name?: string;
token?: string;
token?: UserToken;
} | null): void;
setContext(data: Record<string, string> | null): void;
setConsent(value: boolean): void;
update(changes: WidgetUpdate): void;
destroy(): void;
on<E extends WidgetEventType>(event: E, handler: WidgetEventHandler<E>): () => void;
Expand Down Expand Up @@ -83,10 +84,13 @@ export interface ThemeConfig {

export type ThemeMode = 'light' | 'dark' | 'auto';

export type UserToken = string | (() => string | undefined | Promise<string | undefined>);

export declare class Widget {
get isInline(): boolean;
constructor(config: WidgetConfig);
init(): void;
setConsent(consent: boolean): void;
open(): void;
close(): void;
toggle(): void;
Expand All @@ -98,7 +102,7 @@ export declare class Widget {
setUser(user: {
email?: string;
name?: string;
token?: string;
token?: UserToken;
} | null): void;
setContext(data: Record<string, string> | null): void;
update(changes: WidgetUpdate): void;
Expand All @@ -109,9 +113,10 @@ export declare class Widget {

export interface WidgetConfig {
apiUrl: string;
nonce?: string;
timeout?: number;
turnstileSiteKey?: string;
userToken?: string;
userToken?: UserToken;
mode?: 'popup' | 'inline';
siteId?: string;
container?: string | HTMLElement;
Expand Down Expand Up @@ -148,6 +153,9 @@ export interface WidgetConfig {
onError?: (error: Error) => void;
onLeadCaptured?: (leadData?: Record<string, unknown>) => void;
onPreChatSubmit?: (data: Record<string, string>) => void;
waitForConsent?: boolean;
onConsentRequest?: () => void;
privacyUrl?: string;
}

export type WidgetEventHandler<E extends WidgetEventType = WidgetEventType> = (...args: WidgetEvents[E]) => void;
Expand Down Expand Up @@ -177,6 +185,7 @@ export interface WidgetLocaleStrings {
welcomeMessage: string;
inputPlaceholder: string;
sendButton: string;
stopButton: string;
closeButton: string;
openChat: string;
defaultName: string;
Expand All @@ -202,15 +211,21 @@ export interface WidgetLocaleStrings {
preChatRequired: string;
preChatInvalidEmail: string;
retryButton: string;
messageWaiting: string;
regenerateButton: string;
suggestionsLabel: string;
unreadCount: string;
sourcesLabel: string;
disclosure: string;
disclosureLearnMore: string;
consentNotice: string;
consentButton: string;
moreButton: string;
downloadTranscript: string;
privacy: string;
}

export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings'> & {
export type WidgetUpdate = Pick<WidgetConfig, 'branding' | 'welcomeMessage' | 'disclosure' | 'suggestions' | 'placeholder' | 'strings' | 'privacyUrl'> & {
theme?: Omit<ThemeConfig, 'position'>;
};
```
Expand Down
21 changes: 21 additions & 0 deletions e2e/fixtures/consent.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChatCops e2e host page, waiting for cookie consent</title>
</head>
<body>
<main>
<h1>Host page</h1>
<p>The widget below waits until the visitor accepts functional cookies.</p>
<button type="button" id="accept">Accept functional cookies</button>
<button type="button" id="reject">Reject</button>
</main>
<script src="/chatcops.min.js" data-api-url="/api/chat" data-wait-for-consent></script>
<script>
document.getElementById('accept').addEventListener('click', () => ChatCops.setConsent(true));
document.getElementById('reject').addEventListener('click', () => ChatCops.setConsent(false));
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions e2e/fixtures/csp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChatCops e2e host page, CSP nonce</title>
</head>
<body>
<main>
<h1>Host page</h1>
<p>The widget styles must carry the page's CSP nonce.</p>
</main>
<script nonce="chatcops-e2e" src="/chatcops.min.js" data-api-url="/api/chat"></script>
</body>
</html>
14 changes: 13 additions & 1 deletion e2e/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ const files = {
'/full.html': ['fixtures/full.html', 'text/html'],
'/theme.html': ['fixtures/theme.html', 'text/html'],
'/prechat.html': ['fixtures/prechat.html', 'text/html'],
'/consent.html': ['fixtures/consent.html', 'text/html'],
'/locale.html': ['fixtures/locale.html', 'text/html'],
'/csp.html': ['fixtures/csp.html', 'text/html'],
'/locales/zz.js': ['fixtures/locales/zz.js', 'text/javascript'],
};

Expand Down Expand Up @@ -189,6 +191,12 @@ const endpoints = {
'/api/chat': chatEndpoint(),
// Session tokens on, for the session spec.
'/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'], allowAnonymous: false }),
// Signed-in visitors: the userToken spec signs a token with this secret.
'/api/identity-chat': chatEndpoint({
sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'],
identity: { secret: 'an-e2e-identity-secret-of-32-bytes-or-more' },
allowAnonymous: false,
}),
};

/** Runs a Fetch API handler for a Node request. */
Expand Down Expand Up @@ -273,7 +281,11 @@ const server = createServer(async (req, res) => {

const file = files[url.pathname];
if (file) {
res.writeHead(200, { 'Content-Type': file[1] });
const headers = { 'Content-Type': file[1] };
if (url.pathname === '/csp.html') {
headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'nonce-chatcops-e2e'; style-src 'nonce-chatcops-e2e'; connect-src 'self'; img-src 'self' data:";
}
res.writeHead(200, headers);
let content = await readFile(join(here, file[0]), 'utf8');
content = content.replaceAll('__E2E_API_PORT__', String(apiPort));
return res.end(content);
Expand Down
Loading