diff --git a/.changeset/widget-limit-messages.md b/.changeset/widget-limit-messages.md new file mode 100644 index 00000000..db7c7a9c --- /dev/null +++ b/.changeset/widget-limit-messages.md @@ -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. diff --git a/.size-limit.json b/.size-limit.json index f218d7e0..e9363892 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -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 } ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3937c2bc..5373d5a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index d77e4ce0..e5483aed 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -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; } @@ -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; } @@ -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') { @@ -212,6 +212,19 @@ export class ChatClient { } } +/** The widget's error type for a protocol v1 error code. */ +const ERROR_TYPES: Record = { + 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). @@ -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 }; diff --git a/packages/widget/src/api/protocol.ts b/packages/widget/src/api/protocol.ts index 26c99873..84246894 100644 --- a/packages/widget/src/api/protocol.ts +++ b/packages/widget/src/api/protocol.ts @@ -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 } - | { 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' }; diff --git a/packages/widget/src/api/types.ts b/packages/widget/src/api/types.ts index ae7ff5aa..bdb25187 100644 --- a/packages/widget/src/api/types.ts +++ b/packages/widget/src/api/types.ts @@ -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; suggestedActions?: string[]; diff --git a/packages/widget/src/dom/messages.ts b/packages/widget/src/dom/messages.ts index f627d769..ae6992ca 100644 --- a/packages/widget/src/dom/messages.ts +++ b/packages/widget/src/dom/messages.ts @@ -4,7 +4,7 @@ const RETRY_ICON = '; updatedAt: number; diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index b9652f89..9cc33869 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -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> = { + 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'; @@ -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; @@ -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'; } /** @@ -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 { diff --git a/packages/widget/tests/api/client.test.ts b/packages/widget/tests/api/client.test.ts index 0587c937..4f0028e4 100644 --- a/packages/widget/tests/api/client.test.ts +++ b/packages/widget/tests/api/client.test.ts @@ -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, 'Bad gateway'); + 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 () => { diff --git a/packages/widget/tests/api/session.test.ts b/packages/widget/tests/api/session.test.ts index 53e1fe84..454c264f 100644 --- a/packages/widget/tests/api/session.test.ts +++ b/packages/widget/tests/api/session.test.ts @@ -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 () => { @@ -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); diff --git a/packages/widget/tests/protocol.test-d.ts b/packages/widget/tests/protocol.test-d.ts index 08217abf..42e69488 100644 --- a/packages/widget/tests/protocol.test-d.ts +++ b/packages/widget/tests/protocol.test-d.ts @@ -13,5 +13,7 @@ describe('the protocol types the widget copies from core', () => { expectTypeOf['messageId']>().toEqualTypeOf(); expectTypeOf['delta']>().toEqualTypeOf(); expectTypeOf['lead']>().toEqualTypeOf>(); + expectTypeOf['scope']>().toExtend(); + expectTypeOf['retryAfterMs']>().toEqualTypeOf(); }); }); diff --git a/packages/widget/tests/widget.test.ts b/packages/widget/tests/widget.test.ts index 0bce5b4b..276bde8d 100644 --- a/packages/widget/tests/widget.test.ts +++ b/packages/widget/tests/widget.test.ts @@ -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(); diff --git a/website/src/content/docs/widget/i18n.mdx b/website/src/content/docs/widget/i18n.mdx index 730d6fa9..211b2f26 100644 --- a/website/src/content/docs/widget/i18n.mdx +++ b/website/src/content/docs/widget/i18n.mdx @@ -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; @@ -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.