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/widget-limit-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": minor
---

Clearer messages when a reply can't happen. The widget now reads the server's reason and says it: too many messages, with the wait in seconds when the server sends one; the conversation or the day's budget used up; the assistant unavailable (the provider couldn't be set up); or the reply cut off. Each one is a new `strings` key (`errorRateLimitWait`, `errorBudget`, `errorBudgetDaily`, `errorUnavailable`, `errorCutOff`). The script tag bundle's size budget goes from 14 kB to 14.5 kB (18 kB for ESM) to fit them.
4 changes: 2 additions & 2 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
{
"name": "widget, script tag (IIFE)",
"path": "packages/widget/dist/chatcops.min.js",
"limit": "14 KB",
"limit": "14.5 KB",
"gzip": true
},
{
"name": "widget, ESM",
"path": "packages/widget/dist/chatcops.esm.js",
"limit": "17.5 KB",
"limit": "18 KB",
"gzip": true
}
]
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ ANTHROPIC_API_KEY=sk-... npx tsx src/examples/express-server.ts

`npx` downloads `tsx` the first time.

The widget has a size budget: 14 kB gzipped for the script tag bundle (`dist/chatcops.min.js`), checked in CI by `pnpm size` after a build (limits in `.size-limit.json`).
The widget has a size budget: 14.5 kB gzipped for the script tag bundle (`dist/chatcops.min.js`) and 18 kB for the ESM build, checked in CI by `pnpm size` after a build (limits in `.size-limit.json`).

### Browser tests

Expand Down
29 changes: 21 additions & 8 deletions packages/widget/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,12 @@ export class ChatClient {
}

if (!response.ok) {
// Protocol v1 servers send `{ error: { code, retryAfterMs } }`.
const { error } = await response.json().catch(() => ({}));
await cleanup();
if (response.status === 429) {
yield { error: 'rate_limit' };
return;
}
yield { error: `HTTP ${response.status}` };
yield error?.code
? errorChunk(error.code, error.retryAfterMs)
: { error: response.status === 429 ? 'rate_limit' : `HTTP ${response.status}` };
return;
}

Expand Down Expand Up @@ -173,7 +173,7 @@ export class ChatClient {
const data = trimmed.slice(6);
if (data === '[DONE]') {
await cleanup();
yield v1 && !finished ? { error: 'network' } : { done: true };
yield v1 && !finished ? { error: 'cut_off' } : { done: true };
return;
}

Expand All @@ -198,7 +198,7 @@ export class ChatClient {
// A [DONE] with no newline after it still counts. Anything else means the
// connection was cut mid-reply (a proxy timeout, a server crash), so the
// visitor should see an error and a Retry button rather than half an answer.
yield buffer.trim() === 'data: [DONE]' ? { done: true } : { error: 'network' };
yield buffer.trim() === 'data: [DONE]' ? { done: true } : { error: v1 ? 'cut_off' : 'network' };
} catch (err) {
await cleanup();
if (err instanceof DOMException && err.name === 'AbortError') {
Expand All @@ -212,6 +212,19 @@ export class ChatClient {
}
}

/** The widget's error type for a protocol v1 error code. */
const ERROR_TYPES: Record<string, string> = {
rate_limited: 'rate_limit',
timeout: 'timeout',
budget_exceeded: 'budget',
budget_daily: 'budget_daily',
provider_unavailable: 'unavailable',
};

function errorChunk(code: string, retryAfterMs?: number): WidgetChatChunk {
return { error: ERROR_TYPES[code] ?? 'provider_error', retryAfterMs };
}

/**
* A protocol v1 part as the chunk the widget already handles, or null for
* parts it doesn't use (and part types added later).
Expand All @@ -225,7 +238,7 @@ function fromPart(part: WirePart): WidgetChatChunk | null {
case 'lead-captured':
return { leadCaptured: true, leadData: part.lead };
case 'error':
return { error: part.code === 'rate_limited' ? 'rate_limit' : part.code === 'timeout' ? 'timeout' : 'provider_error' };
return errorChunk(part.code === 'budget_exceeded' && part.scope !== 'conversation' ? 'budget_daily' : part.code, part.retryAfterMs);
case 'finish':
// An error part already went out, or nobody is listening.
return part.reason === 'error' || part.reason === 'aborted' ? null : { done: true };
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/api/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type WirePart =
| { type: 'start'; protocol: 1; conversationId: string; messageId: string }
| { type: 'text-delta'; id: string; delta: string }
| { type: 'lead-captured'; lead: Record<string, unknown> }
| { type: 'error'; code: string; message?: string; retryable: boolean; retryAfterMs?: number }
| { type: 'error'; code: string; message?: string; retryable: boolean; retryAfterMs?: number; scope?: string }
| { type: 'finish'; reason: string }
/** Parts the widget doesn't use yet. */
| { type: 'text-start' | 'text-end' | 'source' | 'tool-call' | 'tool-result' | 'handoff' | 'agent-message' | 'suggestions' };
2 changes: 2 additions & 0 deletions packages/widget/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface WidgetChatChunk {
content?: string;
done?: boolean;
error?: string;
/** How long to wait before trying again, with a `rate_limit` error. */
retryAfterMs?: number;
leadCaptured?: boolean;
leadData?: Record<string, unknown>;
suggestedActions?: string[];
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/dom/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const RETRY_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden=
const REGENERATE_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0020 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 004 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>';

export type MessageStatus = 'streaming' | 'complete' | 'error';
export type MessageErrorType = 'rate_limit' | 'network' | 'provider_error' | 'timeout';
export type MessageErrorType = 'rate_limit' | 'network' | 'provider_error' | 'timeout' | 'budget' | 'budget_daily' | 'unavailable' | 'cut_off';

export interface MessageData {
id: string;
Expand Down
11 changes: 11 additions & 0 deletions packages/widget/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export interface WidgetLocaleStrings {
errorGeneric: string;
errorNetwork: string;
errorRateLimit: string;
/** `{seconds}` is how long to wait. */
errorRateLimitWait: string;
errorBudget: string;
errorBudgetDaily: string;
errorUnavailable: string;
errorCutOff: string;
typingIndicator: string;
poweredBy: string;
newConversation: string;
Expand Down Expand Up @@ -39,6 +45,11 @@ const en: WidgetLocaleStrings = {
errorGeneric: 'Something went wrong. Please try again.',
errorNetwork: 'Connection error. Please check your network.',
errorRateLimit: 'Too many messages. Please wait a moment.',
errorRateLimitWait: 'Too many messages. Please try again in {seconds} seconds.',
errorBudget: 'This chat has reached its limit.',
errorBudgetDaily: 'The assistant has reached its limit for today. Please try again later.',
errorUnavailable: 'The assistant is unavailable right now. Please try again later.',
errorCutOff: 'The reply was cut off.',
typingIndicator: 'Typing...',
poweredBy: 'Powered by ChatCops',
newConversation: 'New conversation',
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ interface StoredConversation {
role: 'user' | 'assistant';
content: string;
status?: 'streaming' | 'complete' | 'error';
errorType?: 'rate_limit' | 'network' | 'provider_error' | 'timeout';
errorType?: import('./dom/messages.js').MessageErrorType;
timestamp: number;
}>;
updatedAt: number;
Expand Down
38 changes: 19 additions & 19 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ import { PreChatForm } from './dom/prechat-form.js';
import type { WidgetChatRequest } from './api/types.js';
import type { MessageData, MessageErrorType } from './dom/messages.js';

/** The locale string for each kind of failed reply. */
const ERROR_MESSAGES: Partial<Record<MessageErrorType, keyof WidgetLocaleStrings>> = {
network: 'errorNetwork',
timeout: 'errorNetwork',
provider_error: 'errorGeneric',
budget: 'errorBudget',
budget_daily: 'errorBudgetDaily',
unavailable: 'errorUnavailable',
cut_off: 'errorCutOff',
};

export interface PreChatField {
name: string;
type: 'text' | 'email' | 'select' | 'textarea';
Expand Down Expand Up @@ -486,7 +497,7 @@ export class Widget {
if (this.destroyed) break;
if (chunk.error) {
const errorType = this.resolveErrorType(chunk.error);
this.markFailed(assistantMsg, this.getErrorMessage(errorType));
this.markFailed(assistantMsg, this.getErrorMessage(errorType, chunk.retryAfterMs));
assistantMsg.errorType = errorType;
hasError = true;

Expand Down Expand Up @@ -658,15 +669,7 @@ export class Widget {
}

private resolveErrorType(error: string): MessageErrorType {
switch (error) {
case 'rate_limit':
case 'network':
case 'provider_error':
case 'timeout':
return error;
default:
return 'provider_error';
}
return error in ERROR_MESSAGES || error === 'rate_limit' ? (error as MessageErrorType) : 'provider_error';
}

/**
Expand All @@ -682,16 +685,13 @@ export class Widget {
}
}

private getErrorMessage(errorType: MessageErrorType): string {
switch (errorType) {
case 'rate_limit':
return this.locale.errorRateLimit;
case 'network':
case 'timeout':
return this.locale.errorNetwork;
default:
return this.locale.errorGeneric;
private getErrorMessage(errorType: MessageErrorType, retryAfterMs?: number): string {
if (errorType === 'rate_limit') {
return retryAfterMs
? this.locale.errorRateLimitWait.replace('{seconds}', String(Math.ceil(retryAfterMs / 1000)))
: this.locale.errorRateLimit;
}
return this.locale[ERROR_MESSAGES[errorType] ?? 'errorGeneric'];
}

private saveHistory(): void {
Expand Down
28 changes: 25 additions & 3 deletions packages/widget/tests/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,18 +358,40 @@ describe('ChatClient with protocol v1', () => {
part({ type: 'finish', reason: 'error' }),
'[DONE]',
]);
expect((await collect()).slice(1, 2)).toEqual([{ error: 'rate_limit' }]);
expect((await collect()).slice(1, 2)).toEqual([{ error: 'rate_limit', retryAfterMs: 5000 }]);

respond([part({ type: 'error', code: 'timeout', retryable: true }), part({ type: 'finish', reason: 'error' }), '[DONE]']);
expect((await collect())[0]).toEqual({ error: 'timeout' });

respond([part({ type: 'error', code: 'budget_exceeded', retryable: false }), part({ type: 'finish', reason: 'error' }), '[DONE]']);
respond([part({ type: 'error', code: 'budget_exceeded', retryable: false, scope: 'conversation' }), part({ type: 'finish', reason: 'error' }), '[DONE]']);
expect((await collect())[0]).toEqual({ error: 'budget' });

respond([part({ type: 'error', code: 'budget_exceeded', retryable: false, scope: 'ip' }), part({ type: 'finish', reason: 'error' }), '[DONE]']);
expect((await collect())[0]).toEqual({ error: 'budget_daily' });

respond([part({ type: 'error', code: 'provider_error', retryable: true }), part({ type: 'finish', reason: 'error' }), '[DONE]']);
expect((await collect())[0]).toEqual({ error: 'provider_error' });
});

it('reads the error body of a refusal before the stream', async () => {
const refuse = (status: number, body: string) => vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(body, { status }));

refuse(429, JSON.stringify({ error: { code: 'rate_limited', retryAfterMs: 42_000 } }));
expect(await collect()).toEqual([{ error: 'rate_limit', retryAfterMs: 42_000 }]);

refuse(503, JSON.stringify({ error: { code: 'provider_unavailable' } }));
expect(await collect()).toEqual([{ error: 'unavailable' }]);

// A 0.5 server, or a proxy page: no JSON body.
refuse(429, 'Too Many Requests');
expect(await collect()).toEqual([{ error: 'rate_limit' }]);
refuse(502, '<html>Bad gateway</html>');
expect(await collect()).toEqual([{ error: 'HTTP 502' }]);
});

it('reports a v1 stream that ends without finish as cut off', async () => {
respond([part({ type: 'start', protocol: 1, conversationId: 'c1', messageId: 'm' }), part({ type: 'text-delta', id: 't', delta: 'Half' }), '[DONE]']);
expect((await collect()).at(-1)).toEqual({ error: 'network' });
expect((await collect()).at(-1)).toEqual({ error: 'cut_off' });
});

it('still reads the 0.5 format from older servers', async () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/widget/tests/api/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('ChatClient with session tokens', () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(unauthorized())
.mockResolvedValueOnce(new Response('{}', { status: 404 }));
expect(await collect(new ChatClient({ apiUrl: API }))).toEqual([{ error: 'HTTP 401' }]);
expect(await collect(new ChatClient({ apiUrl: API }))).toEqual([{ error: 'provider_error' }]);
});

it('keeps working when storage is unavailable', async () => {
Expand Down Expand Up @@ -152,7 +152,7 @@ describe('ChatClient with session tokens', () => {
.mockResolvedValueOnce(v1Reply());
const client = new ChatClient({ apiUrl: API, turnstileSiteKey: 'site-key' });

expect(await collect(client)).toEqual([{ error: 'HTTP 401' }]);
expect(await collect(client)).toEqual([{ error: 'provider_error' }]);
expect(turnstileToken).not.toHaveBeenCalled();
await collect(client);

Expand Down
2 changes: 2 additions & 0 deletions packages/widget/tests/protocol.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ describe('the protocol types the widget copies from core', () => {
expectTypeOf<Extract<CoreWirePart, { type: 'start' }>['messageId']>().toEqualTypeOf<string>();
expectTypeOf<Extract<CoreWirePart, { type: 'text-delta' }>['delta']>().toEqualTypeOf<string>();
expectTypeOf<Extract<CoreWirePart, { type: 'lead-captured' }>['lead']>().toEqualTypeOf<Record<string, unknown>>();
expectTypeOf<Extract<CoreWirePart, { type: 'error' }>['scope']>().toExtend<string | undefined>();
expectTypeOf<Extract<CoreWirePart, { type: 'error' }>['retryAfterMs']>().toEqualTypeOf<number | undefined>();
});
});
32 changes: 32 additions & 0 deletions packages/widget/tests/widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,38 @@ describe('Widget', () => {
expect(lastBubble().dataset.aiGenerated).toBe('true');
});

it.each([
[{ error: 'rate_limit', retryAfterMs: 41_200 }, 'rate_limit', 'Too many messages. Please try again in 42 seconds.'],
[{ error: 'rate_limit' }, 'rate_limit', 'Too many messages. Please wait a moment.'],
[{ error: 'budget' }, 'budget', 'This chat has reached its limit.'],
[{ error: 'budget_daily' }, 'budget_daily', 'The assistant has reached its limit for today. Please try again later.'],
[{ error: 'unavailable' }, 'unavailable', 'The assistant is unavailable right now. Please try again later.'],
[{ error: 'cut_off' }, 'cut_off', 'The reply was cut off.'],
[{ error: 'HTTP 502' }, 'provider_error', 'Something went wrong. Please try again.'],
])('shows %o as %s with its own message', async (chunk, errorType, text) => {
const widget = new Widget({ apiUrl: 'https://api.test/chat' });
widget.init();
getInternals(widget).client.sendMessage = vi.fn().mockImplementationOnce(async function* () {
yield chunk;
});

await getInternals(widget).handleSend('Hi');

expect(getInternals(widget).messages.at(-1)).toMatchObject({ status: 'error', errorType, content: text });
widget.destroy();
});

it('takes the limit messages from strings', async () => {
const widget = new Widget({ apiUrl: 'https://api.test/chat', strings: { errorRateLimitWait: 'Warte {seconds} Sekunden.' } });
widget.init();
getInternals(widget).client.sendMessage = vi.fn().mockImplementationOnce(async function* () {
yield { error: 'rate_limit', retryAfterMs: 3000 };
});
await getInternals(widget).handleSend('Hi');
expect(getInternals(widget).messages.at(-1)?.content).toBe('Warte 3 Sekunden.');
widget.destroy();
});

it('keeps streamed text when an error arrives partway through and shows the error under it', async () => {
const widget = new Widget({ apiUrl: 'https://api.test/chat' });
widget.init();
Expand Down
20 changes: 19 additions & 1 deletion website/src/content/docs/widget/i18n.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ interface LocaleStrings {
errorGeneric: string;
errorNetwork: string;
errorRateLimit: string;
errorRateLimitWait: string; // {seconds} is how long to wait
errorBudget: string;
errorBudgetDaily: string;
errorUnavailable: string;
errorCutOff: string;
typingIndicator: string;
poweredBy: string;
newConversation: string;
Expand All @@ -88,4 +93,17 @@ The server doesn't translate anything itself. When a request has a `locale`, it
Reply in the visitor's language. Their browser locale is es.
```

Error messages the visitor sees come from the widget's `strings` (`errorGeneric`, `errorNetwork`, `errorRateLimit`). The server config's `i18n` option is deprecated: the server ignores it and logs a warning when it's set, and it will be removed in a later release.
Error messages the visitor sees come from the widget's `strings`:

| Key | Shown when | English |
|---|---|---|
| `errorRateLimit` | Too many messages, and the server didn't say for how long | Too many messages. Please wait a moment. |
| `errorRateLimitWait` | Too many messages, with the server's wait (`{seconds}`) | Too many messages. Please try again in \{seconds\} seconds. |
| `errorBudget` | The conversation used up its budget | This chat has reached its limit. |
| `errorBudgetDaily` | The visitor's or the site's daily budget is used up | The assistant has reached its limit for today. Please try again later. |
| `errorUnavailable` | The server couldn't set up the AI provider (a missing key, say) | The assistant is unavailable right now. Please try again later. |
| `errorCutOff` | The reply stopped before the server said it was done | The reply was cut off. |
| `errorNetwork` | The request failed or timed out | Connection error. Please check your network. |
| `errorGeneric` | Anything else | Something went wrong. Please try again. |

The limit and cut-off messages need a 0.6 server, which sends the reason. With an older server, the widget falls back to `errorRateLimit`, `errorNetwork` and `errorGeneric`. The server config's `i18n` option is deprecated: the server ignores it and logs a warning when it's set, and it will be removed in a later release.
Loading