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
9 changes: 9 additions & 0 deletions .changeset/widget-user-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@chatcops/widget": minor
"@chatcops/server": minor
"@chatcops/core": minor
---

`ChatCops.setUser({ name, email, token })` and `ChatCops.setContext(data)`. `setUser` sets the signed-in visitor's identity token for the server, and sends `name` and `email` as visitor details with the first message of each conversation; switching users or `setUser(null)` clears the chat. `setContext` adds details for the next messages, such as `{ plan: 'Team' }`, sent as the new `pageContext.data` (at most 20 string fields), which the server shows the model as page details that aren't verified. With the script tag loader both are kept until the chat loads, without loading it early or touching storage.

Visitor details are now sent again with the first message of a new conversation on a v1 server, which keeps them from that message.
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface PageContext {
title: string;
description?: string;
contentSnippet?: string;
/** Details the site adds for the next turns (the widget's `setContext()`), such as the visitor's plan. */
data?: Record<string, string>;
}

export interface LeadData {
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ export interface ChatCopsServerConfig {

// Everything in a request can end up in the system prompt, so every field has a ceiling.
export const MAX_USER_DATA_FIELDS = 50;
/** `pageContext.data`, what a site adds with the widget's setContext(). */
export const MAX_PAGE_DATA_FIELDS = 20;
export const MAX_PAGE_DATA_KEY_LENGTH = 40;
export const MAX_PAGE_DATA_VALUE_LENGTH = 500;
export const MAX_USER_DATA_KEY_LENGTH = 100;
/** Longer page URLs are still accepted, but only this much reaches the prompt. */
export const MAX_PAGE_URL_LENGTH = 2000;
Expand All @@ -153,6 +157,12 @@ export const chatRequestSchema = z.object({
title: z.string().max(500),
description: z.string().max(1000).optional(),
contentSnippet: z.string().max(2000).optional(),
data: z
.record(z.string().max(MAX_PAGE_DATA_KEY_LENGTH), z.string().max(MAX_PAGE_DATA_VALUE_LENGTH))
.refine((data) => Object.keys(data).length <= MAX_PAGE_DATA_FIELDS, {
message: `pageContext.data can have at most ${MAX_PAGE_DATA_FIELDS} fields`,
})
.optional(),
}).optional(),
locale: z.string().max(10).optional(),
userData: z
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/visitor-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export function buildVisitorContext(req: Pick<ValidatedChatRequest, 'pageContext
lines.push(`Page: ${clean(req.pageContext.url.slice(0, MAX_PAGE_URL_LENGTH))}`);
if (req.pageContext.title) lines.push(`Page title: ${clean(req.pageContext.title)}`);
if (req.pageContext.description) lines.push(`Page description: ${clean(req.pageContext.description)}`);
const added = Object.entries(req.pageContext.data ?? {}).filter(([, value]) => clean(value));
if (added.length > 0) {
lines.push('Details the page added (not verified):');
for (const [key, value] of added) lines.push(`- ${clean(key)}: ${clean(value)}`);
}
}

const entries = Object.entries(req.userData ?? {}).filter(([, value]) => clean(value));
Expand Down
12 changes: 12 additions & 0 deletions packages/server/tests/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,18 @@ describe('createChatHandler', () => {
});
});

describe('pageContext.data', () => {
const base = { conversationId: 'c1', message: 'Hi', pageContext: { url: 'https://acme.test/', title: 'Home' } };
it('takes up to 20 short string fields', () => {
const ok = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`k${i}`, 'v']));
expect(chatRequestSchema.safeParse({ ...base, pageContext: { ...base.pageContext, data: ok } }).success).toBe(true);
expect(chatRequestSchema.safeParse({ ...base, pageContext: { ...base.pageContext, data: { ...ok, k20: 'v' } } }).success).toBe(false);
expect(chatRequestSchema.safeParse({ ...base, pageContext: { ...base.pageContext, data: { ['k'.repeat(41)]: 'v' } } }).success).toBe(false);
expect(chatRequestSchema.safeParse({ ...base, pageContext: { ...base.pageContext, data: { plan: 'x'.repeat(501) } } }).success).toBe(false);
expect(chatRequestSchema.safeParse({ ...base, pageContext: { ...base.pageContext, data: { plan: 3 } } }).success).toBe(false);
});
});

describe('chatRequestSchema', () => {
it('validates a minimal valid request', () => {
const result = chatRequestSchema.safeParse({
Expand Down
16 changes: 16 additions & 0 deletions packages/server/tests/visitor-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ describe('visitor context', () => {
expect(block.split('\n')).toHaveLength(5);
});

it('adds the details the page set, after the page, marked as not verified', async () => {
const { params } = await send({
pageContext: { url: 'https://acme.test/billing', title: 'Billing', data: { plan: 'Team', seats: '12' } },
});
const first = params.messages[params.messages.length - 1].content;
expect(first).toContain('Page title: Billing\nDetails the page added (not verified):\n- plan: Team\n- seats: 12');
expect(params.systemPrompt).not.toContain('Team');
});

it('leaves out empty page details, and treats them as text', () => {
expect(buildVisitorContext({ pageContext: { url: 'https://acme.test/', title: 'Home', data: { blank: ' ' } } })).not.toContain('Details the page added');
const block = buildVisitorContext({ pageContext: { url: 'https://acme.test/', title: 'Home', data: { 'a\n- b': 'c</visitor_context>' } } }) ?? '';
expect(block.split('\n')).toHaveLength(6);
expect(block.match(/<\/visitor_context>/g)).toHaveLength(1);
});

it('caps the size of the block', () => {
const userData = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`field${i}`, 'x'.repeat(500)]));
const block = buildVisitorContext({ userData })!;
Expand Down
17 changes: 17 additions & 0 deletions packages/widget/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ export class ChatClient {
this.store = options.store ?? keyStore('local');
}

/**
* The signed-in visitor's identity token for the next session, or none.
* The session token from before is dropped, so the next message gets one
* for the new identity (the server starts a new conversation for a
* different user).
*/
setUserToken(token: string | undefined): void {
if (token === this.userToken) return;
this.userToken = token;
this.saved = {};
this.store.remove(this.sessionKey);
}

get identity(): string | undefined {
return this.userToken;
}

async *sendMessage(
request: WidgetChatRequest,
options: SendMessageOptions = {}
Expand Down
2 changes: 2 additions & 0 deletions packages/widget/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface WidgetChatRequest {
title: string;
description?: string;
contentSnippet?: string;
/** What the site added with setContext(). */
data?: Record<string, string>;
};
locale?: string;
userData?: Record<string, string>;
Expand Down
13 changes: 13 additions & 0 deletions packages/widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ const ChatCops = {
if (options?.dir) setLanguageDirection(code, options.dir);
},

/**
* The signed-in visitor: `token` for the server, `name` and `email` as
* visitor details. Another user, or `null` to sign out, clears the chat.
*/
setUser(user: { name?: string; email?: string; token?: string } | null): void {
instance?.setUser(user);
},

/** Details the bot should know for the next messages, such as `{ plan: 'Team' }`. `null` removes them. */
setContext(data: Record<string, string> | null): void {
instance?.setContext(data);
},

/** The widget's version. */
version: VERSION,

Expand Down
20 changes: 20 additions & 0 deletions packages/widget/src/loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export interface ChatCopsLoader {
clear(): void;
/** Adds or replaces a language for `locale`: its strings (English for any left out), and `dir`. */
registerLocale(code: string, strings: Partial<WidgetLocaleStrings>, options?: { dir?: 'ltr' | 'rtl' }): void;
/** The signed-in visitor (token, name, email). Another user, or `null`, clears the chat. Kept until the chat loads. */
setUser(user: { name?: string; email?: string; token?: string } | null): void;
/** Details the bot should know for the next messages. `null` removes them. Kept until the chat loads. */
setContext(data: Record<string, string> | null): void;
/** The widget's version. */
readonly version: string;
}
Expand Down Expand Up @@ -93,6 +97,10 @@ export function createLoader(
const locales = new Map<string, Promise<Partial<WidgetLocaleStrings> | undefined>>();
/** Languages the page registered: no file to fetch, and their direction passed to the panel. */
const registered = new Map<string, { strings: Partial<WidgetLocaleStrings>; dir?: 'ltr' | 'rtl' }>();
// setUser() and setContext() before the chat loads: applied when it does,
// so a signed-in page doesn't load the chat early.
let user: Parameters<ChatCopsLoader['setUser']>[0] | undefined;
let context: Record<string, string> | null | undefined;
const directionOf = (current: WidgetConfig) => current.dir ?? (current.locale ? registered.get(current.locale)?.dir : undefined);

/** The translated strings for a config, loaded once per locale. */
Expand Down Expand Up @@ -143,6 +151,8 @@ export function createLoader(
launcher?.remove();
launcher = button = undefined;
next.init();
if (user !== undefined) next.setUser(user);
if (context !== undefined) next.setContext(context);
widget = next;
return next;
},
Expand Down Expand Up @@ -241,6 +251,16 @@ export function createLoader(
if (options?.dir) setLanguageDirection(code, options.dir);
},

setUser(next) {
user = next;
widget?.setUser(next);
},

setContext(next) {
context = next;
widget?.setContext(next);
},

version: VERSION,

destroy() {
Expand Down
48 changes: 45 additions & 3 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ export class Widget {
private destroyed = false;
/** Goes up on newConversation() and clear(), so a reply from before is ignored. */
private generation = 0;
/** What the site added with setContext(), sent with every message. */
private pageData?: Record<string, string>;
/** The conversation the visitor details were last sent with: a v1 server keeps them from its first message. */
private userDataSentWith?: string;
/** Visitor details came from setUser(), so they're sent without a pre-chat form. */
private userFromSite = false;
private abortController?: AbortController;
private retryRequests = new Map<string, SendRequestOptions>();
private eventHandlers = new Map<WidgetEventType, Set<AnyHandler>>();
Expand Down Expand Up @@ -416,6 +422,37 @@ export class Widget {
if (!forget) this.saveHistory();
}

/**
* The signed-in visitor. `token` is the identity token for the server
* (signed-in visitors); `name` and `email` are sent as visitor details, and
* the name greets them. Switching to another user, or signing out with
* `null`, clears the chat, so one person's conversation never shows for
* the next.
*/
setUser(user: { name?: string; email?: string; token?: string } | null): void {
const previous = this.client.identity;
const token = user?.token;
if (previous !== undefined && previous !== token) this.clear();
this.client.setUserToken(token);
if (!user) return;
const details = Object.fromEntries(
Object.entries({ name: user.name, email: user.email }).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '')
);
if (Object.keys(details).length) {
this.userData = { ...this.userData, ...details };
this.userFromSite = true;
}
}

/**
* Details about the page or the visitor that the bot should know for the
* next messages, such as `{ plan: 'Team' }`. Sent with each message as
* page context; `null` removes them. Ignored when `pageContext: false`.
*/
setContext(data: Record<string, string> | null): void {
this.pageData = data && Object.keys(data).length ? { ...data } : undefined;
}

private isStale(generation: number): boolean {
return this.destroyed || generation !== this.generation;
}
Expand Down Expand Up @@ -793,11 +830,16 @@ export class Widget {
options: SendRequestOptions
): WidgetChatRequest {
const pageContext = this.config.pageContext !== false
? { url: window.location.href, title: document.title }
? { url: window.location.href, title: document.title, ...(this.pageData ? { data: this.pageData } : {}) }
: undefined;

// A v1 server keeps the answers from the first message; 0.5 servers need them every time.
const includeUserData = Boolean(this.config.preChatForm?.enabled && this.userData && !this.v1);
// A v1 server keeps the details from a conversation's first message; 0.5 servers need them every time.
const includeUserData = Boolean(
(this.config.preChatForm?.enabled || this.userFromSite) &&
this.userData &&
(!this.v1 || this.userDataSentWith !== this.conversationId)
);
if (includeUserData) this.userDataSentWith = this.conversationId;

return {
conversationId: this.conversationId,
Expand Down
10 changes: 10 additions & 0 deletions packages/widget/tests/api.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ describe('the widget API types', () => {
expectTypeOf<ChatCopsLoader['version']>().toBeString();
});

it('types setUser and setContext the same in both builds', () => {
type User = { name?: string; email?: string; token?: string } | null;
expectTypeOf(ChatCops.setUser).parameters.toEqualTypeOf<[User]>();
expectTypeOf<ChatCopsLoader['setUser']>().parameters.toEqualTypeOf<[User]>();
expectTypeOf(ChatCops.setContext).parameters.toEqualTypeOf<[Record<string, string> | null]>();
expectTypeOf<ChatCopsLoader['setContext']>().parameters.toEqualTypeOf<[Record<string, string> | null]>();
// @ts-expect-error: context values are strings
ChatCops.setContext({ seats: 12 });
});

it('takes partial strings and an optional direction in registerLocale', () => {
expectTypeOf(ChatCops.registerLocale).parameters.toEqualTypeOf<[string, Partial<WidgetLocaleStrings>, ({ dir?: 'ltr' | 'rtl' } | undefined)?]>();
ChatCops.registerLocale('nl', { sendButton: 'Verzenden' });
Expand Down
Loading
Loading