Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/legacy-format-through-1x.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

The 0.5 stream format, for widgets older than 0.6 and clients that don't send `protocol: 1`, stays through 1.x and goes away in 2.0. The startup note and the docs said 1.0.
18 changes: 18 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"mode": "pre",
"tag": "rc",
"initialVersions": {
"@chatcops/cli": "0.1.0",
"@chatcops/core": "0.6.0",
"@chatcops/ingest": "0.1.0",
"@chatcops/server": "0.7.0",
"@chatcops/store-cloudflare": "0.2.0",
"@chatcops/store-postgres": "0.2.0",
"@chatcops/store-redis": "0.2.0",
"@chatcops/store-sqlite": "0.2.0",
"@chatcops/store-upstash-vector": "0.1.0",
"@chatcops/widget": "0.7.0",
"@chatcops/website": "0.0.6"
},
"changesets": []
}
12 changes: 12 additions & 0 deletions .changeset/remove-deprecated-apis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@chatcops/core": major
"@chatcops/server": major
"@chatcops/widget": major
---

Removes the APIs that 0.6 to 0.8 deprecated. Each one now fails at startup with a message naming the replacement, and [Upgrading to 1.0](https://chat.codercops.com/getting-started/upgrading-to-1/) has a step for each.

- Server: `cors` (use `allowedOrigins`), `conversationStore` (use `storage: { conversations }`), `i18n` (ignored since 0.6), and the `RateLimiter` and `RateLimitConfig` exports (use `createRateLimiter` from `@chatcops/core`). Webhook bodies no longer repeat `event` and `timestamp`: read `type` and `created`.
- Core: analytics stats drop `leadsCapture` (use `leadsCaptured`), and the `noun:verb` event names are no longer read as `noun.verb` ones or repeated in `eventCounts`. Events a store saved under them still count.
- Core: the Claude, OpenAI and Gemini providers no longer have `chat()` and `chatSync()`; use `runTurn()`. On `AIProvider`, `chat()` is optional and `chatSync()` is gone, so a custom provider needs `step()` or `chat()`.
- Widget: `persistHistory` (use `persistence: 'none'`). Setting it throws, so a site that asked for nothing to be saved never starts saving.
5 changes: 5 additions & 0 deletions .changeset/retention-days-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

`createChatHandler` now throws when `retention.conversationsDays` is 0, negative or not a finite number. Before, 0 or a negative value made `purge()` delete every conversation.
5 changes: 5 additions & 0 deletions .changeset/sessions-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": major
---

Session tokens are required by default. A server without `sessionSecrets` refuses to start unless it sets `allowAnonymous: true`, so a chat can't be left open to guessed or leaked conversation ids by accident. `requireSession` is removed: `requireSession: false` is now `allowAnonymous: true` next to `sessionSecrets`, and `true` was already the default. The widget fetches tokens itself from 0.6, but it asks `<chat url>/session`, so with Express mount the handler with `app.use('/chat', ...)`, and on Vercel or Next.js add a `/session` route that re-exports the chat route. `chatcops test` runs the server in process without needing tokens. See [Upgrading to 1.0](https://chat.codercops.com/getting-started/upgrading-to-1/).
5 changes: 5 additions & 0 deletions .changeset/text-assertions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/cli": minor
---

`chatcops test` has `icontains`, `regex` and `equals` text assertions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [dev]
pull_request:
branches: [dev, production]
# The Update screenshots workflow starts this on the branch it committed to,
# since its own pushes don't trigger workflows.
workflow_dispatch:

jobs:
typecheck-test-build:
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/update-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Update screenshots

# Makes the screenshot baselines in e2e/screenshots/ on CI's Linux Chromium,
# the only place they're compared. Run it from the Actions tab on a branch
# in this repo after a change to how the widget looks. For dev it opens a PR
# instead, since dev only takes changes through PRs.
on:
workflow_dispatch:
inputs:
branch:
description: Branch to update (a branch in this repo, or dev for a new PR)
required: true
default: dev

permissions:
contents: write
pull-requests: write
actions: write

jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch }}

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Build the widget and the server
run: pnpm --filter @chatcops/widget --filter "@chatcops/server..." build

- name: Install Chromium
run: pnpm exec playwright install --with-deps chromium

- name: Make the screenshots
run: pnpm e2e visual.spec.ts --update-snapshots
env:
E2E_UPDATE_SCREENSHOTS: '1'

- name: Commit them
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ inputs.branch }}
RUN_ID: ${{ github.run_id }}
run: |
git add e2e/screenshots
if git diff --cached --quiet; then
echo "The screenshots are up to date."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "test(e2e): update screenshot baselines"
target="$BRANCH"
if [ "$BRANCH" = dev ] || [ "$BRANCH" = production ]; then
target="screenshots/$RUN_ID"
git push origin "HEAD:refs/heads/$target"
gh pr create --base "$BRANCH" --head "$target" \
--title "test(e2e): update screenshot baselines" \
--body "Made by the Update screenshots workflow, run $RUN_ID. Check the images in the Files tab before merging."
else
git push origin "HEAD:refs/heads/$target"
fi
# Pushes made with this workflow's token don't start CI on their own.
gh workflow run ci.yml --ref "$target"
4 changes: 2 additions & 2 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": "17 KB",
"limit": "18 KB",
"gzip": true
},
{
Expand All @@ -20,7 +20,7 @@
{
"name": "widget, single file (chatcops.full.min.js)",
"path": "packages/widget/dist/chatcops.full.min.js",
"limit": "19 KB",
"limit": "20 KB",
"gzip": true
},
{
Expand Down
6 changes: 4 additions & 2 deletions 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`), 17 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 19 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`), 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.

### Browser tests

Expand All @@ -224,7 +224,9 @@ pnpm e2e

With Google Chrome installed you can skip the Chromium download and run `E2E_CHANNEL=chrome pnpm e2e`.

They cover starting from the script tag, streaming, Escape and the tab order, the phone layout, a reply that fails or is cut off, a slow tool kept alive by pings, session tokens, and a chat API on another origin. `e2e/tests/states.spec.ts` puts the widget in each of its states (launcher, unread, welcome bubble, suggestions, pre-chat form, streaming, sources, errors, rate limit, offline, conversation limit, phone) in light, dark and right-to-left, and checks each one with axe against WCAG 2.2 AA. When you add a state, add it there too. CI runs them all in the `e2e` job. Add a test there when you change how the widget behaves in a browser.
They cover starting from the script tag, streaming, Escape and the tab order, the phone layout, a reply that fails or is cut off, a slow tool kept alive by pings, session tokens, and a chat API on another origin. `e2e/tests/states.spec.ts` puts the widget in each of its states (launcher, unread, welcome bubble, suggestions, pre-chat form, streaming, sources, errors, rate limit, offline, conversation limit, phone) in light, dark and right-to-left, and checks each one with axe against WCAG 2.2 AA. When you add a state, add it there too. CI runs them all in the `e2e` job.

`e2e/tests/visual.spec.ts` compares screenshots of the main states, in light, dark and right-to-left, with the baselines in `e2e/screenshots/`. Fonts render differently on every OS, so the baselines are made on CI's Linux Chromium and the spec skips anywhere else, your machine included. When you change how the widget looks, a maintainer runs the "Update screenshots" workflow (Actions tab, Run workflow) on your branch. It commits the new images there. Look at them in the PR's Files tab: the diff is the review. GitHub doesn't run CI for a commit a workflow pushes, so the workflow starts CI on the branch itself. That run is under Actions, not on the PR. Check that it passed before merging. For a PR from a fork, which the workflow can't push to, the maintainer updates the images after merging. Run on `dev`, the workflow opens a PR with them, and that PR's CI waits for a maintainer to approve the run. Add a test there when you change how the widget behaves in a browser.

## Adding a new AI provider

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,13 @@ import { chatcopsMiddleware } from '@chatcops/server';
const app = express();
app.use(express.json());

app.all('/chat', chatcopsMiddleware({
app.use('/chat', chatcopsMiddleware({
provider: {
type: 'claude',
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
}));

app.listen(3001);
Expand Down
10 changes: 1 addition & 9 deletions api/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ export interface AIProvider {
model?: string;
citations?: 'native';
step?(params: ProviderStepParams): AsyncGenerator<ProviderEvent>;
chat(params: ProviderChatParams): AsyncGenerator<string>;
chatSync(params: ProviderChatParams): Promise<string>;
chat?(params: ProviderChatParams): AsyncGenerator<string>;
}

export type AiSdkChunk = {
Expand Down Expand Up @@ -46,7 +45,6 @@ export interface AnalyticsStats {
totalConversations: number;
totalMessages: number;
leadsCaptured: number;
leadsCapture: number;
averageMessagesPerConversation: number;
eventCounts: Record<string, number>;
}
Expand Down Expand Up @@ -1136,8 +1134,6 @@ export declare class ClaudeProvider implements AIProvider {
readonly model: string;
constructor(config: ProviderConfig);
step(params: ProviderStepParams): AsyncGenerator<ProviderEvent>;
chat(params: ProviderChatParams): AsyncGenerator<string>;
chatSync(params: ProviderChatParams): Promise<string>;
}

export declare function claudeRejectsTemperature(model: string): boolean;
Expand All @@ -1163,8 +1159,6 @@ export declare class OpenAIProvider implements AIProvider {
compatible?: boolean;
});
step(params: ProviderStepParams): AsyncGenerator<ProviderEvent>;
chat(params: ProviderChatParams): AsyncGenerator<string>;
chatSync(params: ProviderChatParams): Promise<string>;
}

export declare function toOpenAIStepMessages(systemPrompt: string, messages: ChatMessage[]): MessageParam[];
Expand All @@ -1178,8 +1172,6 @@ export declare class GeminiProvider implements AIProvider {
readonly model: string;
constructor(config: ProviderConfig);
step(params: ProviderStepParams): AsyncGenerator<ProviderEvent>;
chat(params: ProviderChatParams): AsyncGenerator<string>;
chatSync(params: ProviderChatParams): Promise<string>;
}

export declare function geminiSupportsThinkingLevel(model: string): boolean;
Expand Down
24 changes: 1 addition & 23 deletions api/server.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,20 @@ export interface ChatCopsServerConfig {
analytics?: boolean;
analyticsToken?: string;
storage?: ChatCopsStorage;
conversationStore?: ConversationStore;
retention?: {
conversationsDays?: number;
};
allowedOrigins?: string[];
cors?: string;
clientIp?: ClientIpResolver;
sessionSecrets?: string[];
sessionTtlSec?: number;
requireSession?: boolean;
allowAnonymous?: boolean;
turnstile?: TurnstileConfig;
identity?: {
secret: string;
tiers?: Record<string, Budgets>;
};
exposeUsage?: boolean;
i18n?: {
defaultLocale: string;
locales: Record<string, LocaleStrings>;
};
}

export declare function chatcopsVercelHandler(config: ChatCopsServerConfig): (req: Request) => Promise<Response>;
Expand Down Expand Up @@ -168,20 +162,6 @@ export declare function createChatHandler(config: ChatCopsServerConfig): {
startSession: ((body: unknown, origin: string | null, clientIp?: string) => Promise<import("./security/sessions.js").SessionReply>) | undefined;
};

export interface RateLimitConfig {
maxRequests: number;
windowMs: number;
}

export declare class RateLimiter {
constructor(config?: Partial<RateLimitConfig>);
check(key: string): {
allowed: boolean;
retryAfter?: number;
};
cleanup(): void;
}

export declare class ServerAnalytics {
constructor();
track(event: string, data?: Record<string, unknown>): void;
Expand Down Expand Up @@ -226,8 +206,6 @@ export interface WebhookEvent<T = unknown> {
type: string;
created: number;
data: T;
event: string;
timestamp: number;
}
```

Expand Down
1 change: 0 additions & 1 deletion api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ export interface WidgetConfig {
placeholder?: string;
persistence?: Persistence;
historyTtlDays?: number;
persistHistory?: boolean;
maxMessages?: number;
pageContext?: boolean;
autoOpen?: boolean | number;
Expand Down
3 changes: 3 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
// Screenshot baselines (visual.spec.ts), made on CI's Linux Chromium only.
snapshotPathTemplate: '{testDir}/../screenshots/{testFilePath}/{arg}{ext}',
expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled', caret: 'hide' } },
use: {
baseURL: `http://localhost:${port}`,
trace: 'retain-on-failure',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s14-phone-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s14-phone-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s3-bubble-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s3-bubble-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s5-prechat-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s7-sources-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s9-error-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s9-error-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/visual.spec.ts/s9-error-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion e2e/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ function chatEndpoint(overrides = {}) {
tools: [slowLookup],
toolTimeoutMs: 30_000,
budgets: false,
allowAnonymous: true,
...overrides,
};
const handler = createChatHandler(config);
Expand All @@ -187,7 +188,7 @@ function chatEndpoint(overrides = {}) {
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'] }),
'/api/secure-chat': chatEndpoint({ sessionSecrets: ['an-e2e-session-secret-of-32-bytes-or-more'], allowAnonymous: false }),
};

/** Runs a Fetch API handler for a Node request. */
Expand Down
Loading
Loading