diff --git a/.changeset/widget-user-context.md b/.changeset/widget-user-context.md new file mode 100644 index 00000000..02c84300 --- /dev/null +++ b/.changeset/widget-user-context.md @@ -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. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cd5711ae..6abf225b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; } export interface LeadData { diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 57cb7f78..eb8aa846 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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; @@ -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 diff --git a/packages/server/src/visitor-context.ts b/packages/server/src/visitor-context.ts index ed3fd438..814994c4 100644 --- a/packages/server/src/visitor-context.ts +++ b/packages/server/src/visitor-context.ts @@ -34,6 +34,11 @@ export function buildVisitorContext(req: Pick 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)); diff --git a/packages/server/tests/handler.test.ts b/packages/server/tests/handler.test.ts index 00d22d13..6315192b 100644 --- a/packages/server/tests/handler.test.ts +++ b/packages/server/tests/handler.test.ts @@ -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({ diff --git a/packages/server/tests/visitor-context.test.ts b/packages/server/tests/visitor-context.test.ts index 21b619fb..e179d3a4 100644 --- a/packages/server/tests/visitor-context.test.ts +++ b/packages/server/tests/visitor-context.test.ts @@ -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' } } }) ?? ''; + 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 })!; diff --git a/packages/widget/src/api/client.ts b/packages/widget/src/api/client.ts index b99b8525..0655e902 100644 --- a/packages/widget/src/api/client.ts +++ b/packages/widget/src/api/client.ts @@ -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 = {} diff --git a/packages/widget/src/api/types.ts b/packages/widget/src/api/types.ts index 880d53f1..af778348 100644 --- a/packages/widget/src/api/types.ts +++ b/packages/widget/src/api/types.ts @@ -9,6 +9,8 @@ export interface WidgetChatRequest { title: string; description?: string; contentSnippet?: string; + /** What the site added with setContext(). */ + data?: Record; }; locale?: string; userData?: Record; diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 89941df1..5539ab4b 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -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 | null): void { + instance?.setContext(data); + }, + /** The widget's version. */ version: VERSION, diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index 79386e1e..c8e260ca 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -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, 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 | null): void; /** The widget's version. */ readonly version: string; } @@ -93,6 +97,10 @@ export function createLoader( const locales = new Map | undefined>>(); /** Languages the page registered: no file to fetch, and their direction passed to the panel. */ const registered = new Map; 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[0] | undefined; + let context: Record | 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. */ @@ -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; }, @@ -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() { diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 0c00f841..47f39019 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -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; + /** 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(); private eventHandlers = new Map>(); @@ -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 | null): void { + this.pageData = data && Object.keys(data).length ? { ...data } : undefined; + } + private isStale(generation: number): boolean { return this.destroyed || generation !== this.generation; } @@ -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, diff --git a/packages/widget/tests/api.test-d.ts b/packages/widget/tests/api.test-d.ts index 8fc40cd2..3b6a0627 100644 --- a/packages/widget/tests/api.test-d.ts +++ b/packages/widget/tests/api.test-d.ts @@ -14,6 +14,16 @@ describe('the widget API types', () => { expectTypeOf().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().parameters.toEqualTypeOf<[User]>(); + expectTypeOf(ChatCops.setContext).parameters.toEqualTypeOf<[Record | null]>(); + expectTypeOf().parameters.toEqualTypeOf<[Record | 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, ({ dir?: 'ltr' | 'rtl' } | undefined)?]>(); ChatCops.registerLocale('nl', { sendButton: 'Verzenden' }); diff --git a/packages/widget/tests/api.test.ts b/packages/widget/tests/api.test.ts index fe628851..2287c4db 100644 --- a/packages/widget/tests/api.test.ts +++ b/packages/widget/tests/api.test.ts @@ -110,6 +110,94 @@ describe('Widget methods', () => { }); }); +describe('setUser and setContext', () => { + const bodies = () => vi.mocked(globalThis.fetch).mock.calls.map(([, init]) => JSON.parse(String((init as RequestInit).body))); + + it('setContext sends the details as page context with each message, and null removes them', async () => { + const widget = new Widget({ apiUrl: '/chat' }); + widget.init(); + widget.setContext({ plan: 'Team', seats: '12' }); + let stream = mockChatStream(); + let sent = widget.sendMessage('Can I add a seat?'); + await tick(); + await reply(stream, 'Yes.'); + await sent; + widget.setContext(null); + stream = mockChatStream(); + sent = widget.sendMessage('Thanks'); + await tick(); + await reply(stream, 'Anytime.'); + await sent; + const [first, second] = bodies(); + expect(first.pageContext.data).toEqual({ plan: 'Team', seats: '12' }); + expect(second.pageContext.data).toBeUndefined(); + widget.destroy(); + }); + + it('setUser sends name and email as visitor details with the first message, and greets by name', async () => { + const widget = new Widget({ apiUrl: '/chat', welcomeMessage: 'How can I help?' }); + widget.init(); + widget.setUser({ name: 'Sam', email: 'sam@acme.test' }); + widget.newConversation(); + expect(texts(widget).join(' ')).toContain('Sam'); + const stream = mockChatStream(); + const sent = widget.sendMessage('Hi'); + await tick(); + await reply(stream, 'Hello Sam.'); + await sent; + expect(bodies()[0].userData).toEqual({ name: 'Sam', email: 'sam@acme.test' }); + widget.destroy(); + }); + + it('switching users or signing out clears the chat; signing in from anonymous keeps it', async () => { + const widget = new Widget({ apiUrl: '/chat' }); + widget.init(); + const stream = mockChatStream(); + const sent = widget.sendMessage('A question'); + await tick(); + await reply(stream, 'An answer.'); + await sent; + + widget.setUser({ token: 'user-a' }); + expect(internals(widget).messages.some((m) => m.content === 'A question')).toBe(true); + widget.setUser({ token: 'user-a' }); + expect(internals(widget).messages.some((m) => m.content === 'A question')).toBe(true); + + widget.setUser({ token: 'user-b' }); + expect(internals(widget).messages.some((m) => m.content === 'A question')).toBe(false); + internals(widget).messages.push({ role: 'user', content: 'B wrote this' }); + widget.setUser(null); + expect(internals(widget).messages.some((m) => m.content === 'B wrote this')).toBe(false); + widget.destroy(); + }); + + it('sends the visitor details again with the first message of a new conversation', async () => { + const widget = new Widget({ apiUrl: '/chat', preChatForm: { enabled: true, fields: [{ name: 'name', label: 'Name', type: 'text' }] } }); + widget.init(); + internals(widget).userData = { name: 'Sam' }; + (internals(widget) as unknown as { v1: boolean; preChatDone: boolean }).v1 = true; + (internals(widget) as unknown as { preChatDone: boolean }).preChatDone = true; + let stream = mockChatStream(); + let sent = widget.sendMessage('First'); + await tick(); + await reply(stream, 'One.'); + await sent; + stream = mockChatStream(); + sent = widget.sendMessage('Second'); + await tick(); + await reply(stream, 'Two.'); + await sent; + widget.newConversation(); + stream = mockChatStream(); + sent = widget.sendMessage('Fresh start'); + await tick(); + await reply(stream, 'Three.'); + await sent; + expect(bodies().map((body) => body.userData)).toEqual([{ name: 'Sam' }, undefined, { name: 'Sam' }]); + widget.destroy(); + }); +}); + describe('the ChatCops API (npm and single-file builds)', () => { it('toggles, and has the version', () => { ChatCops.init({ apiUrl: '/chat' }); @@ -199,6 +287,33 @@ describe('the script tag loader API', () => { loader.destroy(); }); + it('keeps setUser and setContext until the chat loads, without loading it or touching storage', async () => { + const load = panel(); + const loader = createLoader(load); + const getItem = vi.spyOn(Storage.prototype, 'getItem'); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + const removeItem = vi.spyOn(Storage.prototype, 'removeItem'); + loader.setUser({ name: 'Sam', token: 'user-a' }); + loader.init({ apiUrl: '/chat', welcomeMessage: 'How can I help?' }); + loader.setContext({ plan: 'Team' }); + await tick(); + expect(load).not.toHaveBeenCalled(); + expect(getItem).not.toHaveBeenCalled(); + expect(setItem).not.toHaveBeenCalled(); + expect(removeItem).not.toHaveBeenCalled(); + + const setUser = vi.spyOn(Widget.prototype, 'setUser'); + const setContext = vi.spyOn(Widget.prototype, 'setContext'); + loader.open(); + await tick(); + await tick(); + expect(setUser).toHaveBeenCalledWith({ name: 'Sam', token: 'user-a' }); + expect(setContext).toHaveBeenCalledWith({ plan: 'Team' }); + loader.setContext(null); + expect(setContext).toHaveBeenLastCalledWith(null); + loader.destroy(); + }); + it('registerLocale uses the strings without fetching a file, and passes the direction to the panel', async () => { const loadLocale = vi.fn(); const loader = createLoader(panel(), loadLocale); diff --git a/website/src/content/docs/server/protocol.mdx b/website/src/content/docs/server/protocol.mdx index 6738c960..52e13f91 100644 --- a/website/src/content/docs/server/protocol.mdx +++ b/website/src/content/docs/server/protocol.mdx @@ -78,6 +78,7 @@ Provides current webpage context to the assistant. The server validates: - `title`: String, maximum 500 characters. - `description` *(optional)*: String, maximum 1,000 characters. - `contentSnippet` *(optional)*: String, maximum 2,000 characters. +- `data` *(optional)*: Details the site added with the widget's `setContext()`, at most 20 string fields, keys up to 40 characters and values up to 500. They reach the model as page details that aren't verified. ```json { diff --git a/website/src/content/docs/server/setup.mdx b/website/src/content/docs/server/setup.mdx index 8b54460e..209ad198 100644 --- a/website/src/content/docs/server/setup.mdx +++ b/website/src/content/docs/server/setup.mdx @@ -425,6 +425,7 @@ interface ChatRequest { title: string, // Max 500 chars description?: string, // Max 1000 chars contentSnippet?: string, // Max 2000 chars + data?: { [key: string]: string }, // From the widget's setContext(): max 20 fields, keys 40 chars, values 500 }, locale?: string, // Max 10 chars userData?: { // Optional pre-chat data, max 50 fields diff --git a/website/src/content/docs/widget/api.mdx b/website/src/content/docs/widget/api.mdx index e9d5522e..ba30aeda 100644 --- a/website/src/content/docs/widget/api.mdx +++ b/website/src/content/docs/widget/api.mdx @@ -130,6 +130,32 @@ ChatCops.on('open', onOpen); ChatCops.off('open', onOpen); ``` +### `ChatCops.setUser(user)` + +Tell the widget who the signed-in visitor is. + +- `token` is the identity token for [signed-in visitors](/server/setup/#signed-in-visitors), which your server signs. +- `name` and `email` are sent to the bot as visitor details with the first message of a conversation, and the name greets them. + +Switching to another user, or `setUser(null)` when the visitor signs out, clears the chat, so one person's conversation never shows for the next. With the script tag build, a call before the chat has loaded is kept until it does. + +```typescript +declare const session: { name: string; email: string; chatToken: string }; + +ChatCops.setUser({ name: session.name, email: session.email, token: session.chatToken }); + +// When the visitor signs out: +ChatCops.setUser(null); +``` + +### `ChatCops.setContext(data)` + +Details the bot should know for the next messages, such as the visitor's plan or what's in their cart. They're sent with each message as page context: at most 20 fields, keys up to 40 characters, values up to 500. The bot sees them as details the page added, not as verified facts. `setContext(null)` removes them. Nothing is sent when `pageContext: false`. + +```typescript +ChatCops.setContext({ plan: 'Team', seats: '12' }); +``` + ### `ChatCops.registerLocale(code, strings, options?)` Add a language the widget doesn't ship, or replace one of its own. Strings you leave out stay in English. Pass `{ dir: 'rtl' }` for a right-to-left language. Register before `init()`, then use the code as `locale`.