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

Screen readers now announce new chat messages and the typing indicator, and the message box has an accessible name.
5 changes: 5 additions & 0 deletions .changeset/conversation-store-option.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": minor
---

Support configuring a custom `conversationStore` in `ChatCopsServerConfig`.
5 changes: 5 additions & 0 deletions .changeset/cors-error-responses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

Send the configured CORS header with 400 and 405 error responses across the Cloudflare, Vercel, and Express server adapters.
5 changes: 5 additions & 0 deletions .changeset/deprecate-server-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

Deprecate the `i18n` option, which the server never read. Setting it now logs a warning.
5 changes: 5 additions & 0 deletions .changeset/locale-accents-devanagari.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/core": patch
---

Fix missing accents in the Spanish, French and German locale strings (including the opening ¿/¡ in Spanish questions and exclamations) and write the Hindi locale in Devanagari instead of romanized text.
17 changes: 17 additions & 0 deletions .changeset/locale-ui-strings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@chatcops/widget": patch
---

Move hardcoded English UI text and aria-labels into `WidgetLocaleStrings`.

New keys added to `WidgetLocaleStrings`:
- `openChat` – aria-label for the FAB when the panel is closed
- `defaultName` – default branding name shown in the panel header ("AI Assistant")
- `defaultSubtitle` – default branding subtitle ("Online")
- `greetingWithName` – welcome greeting when the visitor's name is known; supports a `{name}` placeholder
- `selectPlaceholder` – default option text for select fields in the pre-chat form; supports a `{label}` placeholder
- `dismissBubble` – aria-label for the welcome bubble dismiss button

The existing `sendButton` and `closeButton` keys are now also used as aria-labels for the send and close buttons respectively.

The bubble dismiss button gains `type="button"` and an `aria-label`.
10 changes: 10 additions & 0 deletions .changeset/persist-prechat-userdata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@chatcops/widget": patch
---

Persist and restore pre-chat `userData` across widget recreation and send `userData` with every request.

- Persist pre-chat `userData` in `sessionStorage` alongside the completion flag.
- Restore pre-chat `userData` when a widget instance initializes so the welcome message greets returning visitors by name after `destroy()` and recreation in the same tab.
- Send `userData` on every chat request instead of only the first request.
- When `preChatForm` is disabled, do not restore, store, or send form answers.
5 changes: 5 additions & 0 deletions .changeset/regenerate-restore-user-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": patch
---

Restore user message when regenerating a conversation with no existing user message to answer, and keep retries idempotent.
5 changes: 5 additions & 0 deletions .changeset/server-max-tokens-temperature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/server": minor
---

Add `maxTokens` and `temperature` to the server config and pass them to the provider.
5 changes: 5 additions & 0 deletions .changeset/stream-inactivity-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Reset the request timeout on received stream data so active long replies can complete. Expose the inactivity timeout in milliseconds through WidgetConfig.timeout while preserving request cancellation.
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ jobs:
steps:
- uses: actions/checkout@v4

# Some editors save files with a UTF-8 BOM, and a tool that reads UTF-8 with
# the wrong encoding turns a dash or an arrow into two or three other
# characters. Both have reached PRs here, so catch them before review.
- name: No BOM or garbled characters
run: |
if git grep -n -I -F -e $'\xEF\xBB\xBF' -e $'\xCE\x93\xC3\x87' -e $'\xCE\x93\xC3\xA5\xC3\x86' \
-e $'\xE2\x94\xAC\xE2\x95\x96' -e $'\xC3\xA2\xE2\x82\xAC' -e $'\xC3\x83\xC2\xA9'; then
echo "::error::The lines above start with a UTF-8 BOM or have text garbled by the wrong encoding. Save the file as UTF-8 without a BOM, and copy garbled lines back from the base branch."
exit 1
fi

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ We're taking part in Hacktoberfest this year, and first-time contributors are ve
2. Comment on it to ask for it. I'll assign it to you, usually within a day. Please don't start on an issue that's assigned to someone else.
3. Take one issue at a time. Once your PR is merged, grab the next one. After 3 merged PRs with us you can hold two issues at once.
4. If you're assigned and there's no PR or update for 5 days, I'll free the issue up for someone else. Just comment if you need more time.
5. Open your PR against `dev` and put `Closes #<issue number>` in the description. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty.
5. Open your PR against `dev` here in codercops/chatcops and put `Closes #<issue number>` in the description. A PR merged inside your own fork never reaches us, so it doesn't count. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty.

PRs count for Hacktoberfest once they're merged or labeled `hacktoberfest-accepted`. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. The full rules are under [Ground rules](#ground-rules) below.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ That's it. Your site now has an AI chat widget.
- **Knowledge base** - Feed text chunks or FAQ pairs for context-aware responses.
- **Conversation persistence** - localStorage on client, pluggable stores on server.
- **Analytics** - Track conversations, messages, and lead captures.
- **i18n** - 8 built-in locales, fully customizable strings.
- **i18n** - Replace any widget string, and the model replies in the visitor's language.
- **Deployable anywhere** - Express, Vercel, Cloudflare Workers adapters included.
- **Customizable** - Theme, branding, position, welcome messages, callbacks.

Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ export const de: LocaleStrings = {
welcomeMessage: 'Hallo! Wie kann ich Ihnen helfen?',
inputPlaceholder: 'Nachricht eingeben...',
sendButton: 'Senden',
closeButton: 'Schliessen',
closeButton: 'Schließen',
errorGeneric: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.',
errorNetwork: 'Verbindungsfehler. Bitte pruefen Sie Ihr Netzwerk.',
errorNetwork: 'Verbindungsfehler. Bitte prüfen Sie Ihr Netzwerk.',
errorRateLimit: 'Zu viele Nachrichten. Bitte warten Sie einen Moment.',
typingIndicator: 'Tippt...',
poweredBy: 'Betrieben von ChatCops',
newConversation: 'Neues Gespraech',
welcomeBubbleDefault: 'Hilfe benoetigt? Chatten Sie mit uns!',
newConversation: 'Neues Gespräch',
welcomeBubbleDefault: 'Hilfe benötigt? Chatten Sie mit uns!',
};
10 changes: 5 additions & 5 deletions packages/core/src/i18n/es.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { LocaleStrings } from './types.js';

export const es: LocaleStrings = {
welcomeMessage: 'Hola! Como puedo ayudarte hoy?',
welcomeMessage: '¡Hola! ¿Cómo puedo ayudarte hoy?',
inputPlaceholder: 'Escribe un mensaje...',
sendButton: 'Enviar',
closeButton: 'Cerrar',
errorGeneric: 'Algo salio mal. Por favor, intentalo de nuevo.',
errorNetwork: 'Error de conexion. Verifica tu red.',
errorGeneric: 'Algo salió mal. Por favor, inténtalo de nuevo.',
errorNetwork: 'Error de conexión. Verifica tu red.',
errorRateLimit: 'Demasiados mensajes. Espera un momento.',
typingIndicator: 'Escribiendo...',
poweredBy: 'Desarrollado por ChatCops',
newConversation: 'Nueva conversacion',
welcomeBubbleDefault: 'Necesitas ayuda? Chatea con nosotros!',
newConversation: 'Nueva conversación',
welcomeBubbleDefault: '¿Necesitas ayuda? ¡Chatea con nosotros!',
};
6 changes: 3 additions & 3 deletions packages/core/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ export const fr: LocaleStrings = {
inputPlaceholder: 'Tapez un message...',
sendButton: 'Envoyer',
closeButton: 'Fermer',
errorGeneric: 'Une erreur est survenue. Veuillez reessayer.',
errorNetwork: 'Erreur de connexion. Verifiez votre reseau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
errorNetwork: 'Erreur de connexion. Vérifiez votre réseau.',
errorRateLimit: 'Trop de messages. Patientez un instant.',
typingIndicator: 'En train de taper...',
poweredBy: 'Propulse par ChatCops',
poweredBy: 'Propulsé par ChatCops',
newConversation: 'Nouvelle conversation',
welcomeBubbleDefault: 'Besoin d\'aide ? Discutons !',
};
22 changes: 11 additions & 11 deletions packages/core/src/i18n/hi.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { LocaleStrings } from './types.js';

export const hi: LocaleStrings = {
welcomeMessage: 'Namaste! Main aapki kaise madad kar sakta hoon?',
inputPlaceholder: 'Apna sandesh likhen...',
sendButton: 'Bhejen',
closeButton: 'Band karen',
errorGeneric: 'Kuch galat ho gaya. Kripya dobara koshish karen.',
errorNetwork: 'Connection mein samasya. Apna network jaanchen.',
errorRateLimit: 'Bahut saare sandesh. Kripya thoda intezaar karen.',
typingIndicator: 'Likh raha hai...',
poweredBy: 'ChatCops dwara sanchaalit',
newConversation: 'Nayi baatcheet',
welcomeBubbleDefault: 'Madad chahiye? Humse baat karen!',
welcomeMessage: 'नमस्ते! मैं आपकी कैसे मदद कर सकता हूँ?',
inputPlaceholder: 'अपना संदेश लिखें...',
sendButton: 'भेजें',
closeButton: 'बंद करें',
errorGeneric: 'कुछ गलत हो गया। कृपया दोबारा कोशिश करें।',
errorNetwork: 'कनेक्शन में समस्या है। अपना नेटवर्क जाँचें।',
errorRateLimit: 'बहुत सारे संदेश। कृपया थोड़ा इंतज़ार करें।',
typingIndicator: 'लिख रहा है...',
poweredBy: 'ChatCops द्वारा संचालित',
newConversation: 'नई बातचीत',
welcomeBubbleDefault: 'मदद चाहिए? हमसे बात करें!',
};
2 changes: 1 addition & 1 deletion packages/core/tests/i18n/locales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('i18n', () => {

it('returns Hindi locale', () => {
const locale = getLocale('hi');
expect(locale.sendButton).toBe('Bhejen');
expect(locale.sendButton).toBe('भेजें');
});

it('lists available locales', () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ const handler = createChatHandler({
// Analytics (optional)
analytics: true,

// Conversation storage (optional, default: in-memory)
conversationStore: new RedisConversationStore(redisClient),

// CORS origin (optional, default: '*')
cors: '*',
});
Expand Down Expand Up @@ -164,6 +167,41 @@ await dispatcher.dispatch('lead:captured', { email: 'user@example.com' });

Signature sent via `X-ChatCops-Signature` header.

## Custom Conversation Store

By default, conversations are held in memory (`MemoryStore`). You can persist conversations across server restarts or share them across instances by implementing `ConversationStore` from `@chatcops/core`:

```typescript
import type { ConversationStore, Conversation } from '@chatcops/core';

class RedisConversationStore implements ConversationStore {
constructor(private redis: RedisClient) {}

async get(id: string): Promise<Conversation | null> {
const data = await this.redis.get(`conversation:${id}`);
return data ? JSON.parse(data) : null;
}

async save(conversation: Conversation): Promise<void> {
await this.redis.set(`conversation:${conversation.id}`, JSON.stringify(conversation));
}

async delete(id: string): Promise<void> {
await this.redis.del(`conversation:${id}`);
}
}
```

Pass the store when initializing the handler:

```typescript
const handler = createChatHandler({
provider: { type: 'claude', apiKey: process.env.ANTHROPIC_API_KEY },
systemPrompt: 'You are a helpful assistant.',
conversationStore: new RedisConversationStore(redisClient),
});
```

## License

MIT
20 changes: 12 additions & 8 deletions packages/server/src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ export function chatcopsCloudflareHandler(config: ChatCopsServerConfig) {
const { handleChat } = createChatHandler(config);
const corsOrigin = config.cors ?? '*';

function errorResponse(status: number, error: string): Response {
return new Response(JSON.stringify({ error }), {
status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
},
});
}

return async function handler(request: Request): Promise<Response> {
// Handle CORS preflight
if (request.method === 'OPTIONS') {
Expand All @@ -20,21 +30,15 @@ export function chatcopsCloudflareHandler(config: ChatCopsServerConfig) {
}

if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
return errorResponse(405, 'Method not allowed');
}

const clientIp = request.headers.get('cf-connecting-ip') ?? 'unknown';
let body: unknown;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
return errorResponse(400, 'Invalid JSON body');
}

const { readable, writable } = new TransformStream();
Expand Down
7 changes: 6 additions & 1 deletion packages/server/src/adapters/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export function chatcopsMiddleware(config: ChatCopsServerConfig) {
const { handleChat } = getOrCreateChatHandler(config);
const corsOrigin = config.cors ?? '*';

function sendError(res: Response, status: number, error: string) {
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
res.status(status).json({ error });
}

return async function chatcopsHandler(req: Request, res: Response, next: NextFunction) {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
Expand All @@ -31,7 +36,7 @@ export function chatcopsMiddleware(config: ChatCopsServerConfig) {
}

if (req.method !== 'POST') {
res.status(405).json({ error: 'Method not allowed' });
sendError(res, 405, 'Method not allowed');
return;
}

Expand Down
20 changes: 12 additions & 8 deletions packages/server/src/adapters/vercel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ export function chatcopsVercelHandler(config: ChatCopsServerConfig) {
const { handleChat } = createChatHandler(config);
const corsOrigin = config.cors ?? '*';

function errorResponse(status: number, error: string): Response {
return new Response(JSON.stringify({ error }), {
status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
},
});
}

return async function handler(req: Request): Promise<Response> {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
Expand All @@ -20,21 +30,15 @@ export function chatcopsVercelHandler(config: ChatCopsServerConfig) {
}

if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
});
return errorResponse(405, 'Method not allowed');
}

const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
let body: unknown;
try {
body = await req.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
return errorResponse(400, 'Invalid JSON body');
}

const encoder = new TextEncoder();
Expand Down
9 changes: 8 additions & 1 deletion packages/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { z } from 'zod';
import type { ChatTool, KnowledgeSource, WebhookConfig, ProviderConfig, LocaleStrings } from '@chatcops/core';
import type { ChatTool, KnowledgeSource, WebhookConfig, ProviderConfig, LocaleStrings, ConversationStore } from '@chatcops/core';

export interface ChatCopsServerConfig {
provider: ProviderConfig;
systemPrompt: string;
tools?: ChatTool[];
toolTimeoutMs?: number;
/** Most tokens in one reply. Defaults to 1024. */
maxTokens?: number;
/** Sampling temperature. Defaults to the provider's own; the allowed range depends on the provider. */
temperature?: number;
knowledge?: KnowledgeSource[];
rateLimit?: { maxRequests: number; windowMs: number };
webhooks?: WebhookConfig[];
analytics?: boolean;
/** Storage backend for conversation history. Defaults to an in-memory store (`MemoryStore`). */
conversationStore?: ConversationStore;
/** Allowed CORS origin. Defaults to `'*'`. */
cors?: string;
/** @deprecated The server ignores this. Set the widget's text with its `strings` option. */
i18n?: { defaultLocale: string; locales: Record<string, LocaleStrings> };
}

Expand Down
Loading
Loading