botwire-js 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/dist/botwire.js +176 -0
- package/dist/client.d.ts +55 -0
- package/dist/index.cjs +187 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +164 -0
- package/dist/types.d.ts +69 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# botwire-js
|
|
2
|
+
|
|
3
|
+
Framework-agnostic JavaScript/TypeScript client for the [BotWire](https://github.com/adamy/BotWire) support API. Zero DOM, zero dependencies — wraps session init, chat, and SSE streaming so React / Vue / Angular / Blazor apps can build their own UI on top.
|
|
4
|
+
|
|
5
|
+
This is **Layer 1** of the BotWire frontend stack. The `<botwire-widget>` Web Component is Layer 2, built on this client.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install botwire-js
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { BotWireClient } from 'botwire-js';
|
|
17
|
+
|
|
18
|
+
const client = new BotWireClient({ endpoint: '/support' });
|
|
19
|
+
|
|
20
|
+
// Streaming
|
|
21
|
+
let reply = '';
|
|
22
|
+
for await (const e of client.streamChat('How do refunds work?')) {
|
|
23
|
+
switch (e.type) {
|
|
24
|
+
case 'delta': reply += e.delta; break;
|
|
25
|
+
case 'collect_contact': askForEmail(); break; // resend with { contactEmail }
|
|
26
|
+
case 'escalated': showTicket(e.ticketId, e.message); break;
|
|
27
|
+
case 'blocked': warn(e.reason); break;
|
|
28
|
+
case 'done': break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// Non-streaming
|
|
35
|
+
const res = await client.chat('How do refunds work?');
|
|
36
|
+
if (res.status === 'Answered') render(res.message);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The client creates a session automatically on the first call and reuses the token. It self-heals a stale token once (server `400 InvalidSession`, e.g. after an app-pool restart) by rebuilding the session and resending the message — transparent to your code.
|
|
40
|
+
|
|
41
|
+
### Submitting a contact email
|
|
42
|
+
|
|
43
|
+
When a turn yields `collect_contact`, ask the user for an email and resend:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
for await (const e of client.streamChat('', { contactEmail: 'user@example.com' })) { /* ... */ }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
new BotWireClient({
|
|
53
|
+
endpoint: '/support', // base path or absolute URL the server mounted BotWire on (default '/support')
|
|
54
|
+
publicKey: 'pk_...', // optional; sent as the X-BotWire-Key header
|
|
55
|
+
fetch: customFetch, // optional; defaults to global fetch
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> `endpoint` is the **base** path. The client appends `/session`, `/chat`, and `/chat/stream`.
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
| Member | Returns | Notes |
|
|
64
|
+
|--------|---------|-------|
|
|
65
|
+
| `new BotWireClient(config?)` | — | See configuration above. |
|
|
66
|
+
| `initSession(signal?)` | `Promise<InitSessionResult>` | Create a session explicitly. Called for you on first chat. |
|
|
67
|
+
| `chat(message, opts?)` | `Promise<BotWireResponse>` | Non-streaming turn. |
|
|
68
|
+
| `streamChat(message, opts?)` | `AsyncGenerator<BotWireEvent>` | Streaming turn; yields until `done`. |
|
|
69
|
+
| `getSessionToken()` | `string \| null` | Current token. |
|
|
70
|
+
| `setSessionToken(token)` | `void` | Restore a token (e.g. from storage). |
|
|
71
|
+
|
|
72
|
+
`opts`: `{ contactEmail?: string; signal?: AbortSignal }`.
|
|
73
|
+
|
|
74
|
+
### Events (`streamChat`)
|
|
75
|
+
|
|
76
|
+
| `type` | Payload | Meaning |
|
|
77
|
+
|--------|---------|---------|
|
|
78
|
+
| `delta` | `delta: string` | A chunk of assistant text. |
|
|
79
|
+
| `collect_contact` | — | Bot needs a contact email. |
|
|
80
|
+
| `escalated` | `ticketId`, `message` | Escalated to a human; ticket created. |
|
|
81
|
+
| `blocked` | `reason` | Message blocked (PII, length, prompt-injection, off-topic). |
|
|
82
|
+
| `done` | — | Terminal event. |
|
|
83
|
+
|
|
84
|
+
Errors throw `BotWireError` (`{ status, message, httpStatus }`).
|
|
85
|
+
|
|
86
|
+
## Build outputs
|
|
87
|
+
|
|
88
|
+
ESM (`dist/index.js`), CommonJS (`dist/index.cjs`), and type declarations (`dist/index.d.ts`).
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
AGPL-3.0-or-later. Commercial licensing: see the [BotWire repository](https://github.com/adamy/BotWire).
|
package/dist/botwire.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";(()=>{var m="/support",o=class extends Error{constructor(e,i,s){super(i);this.status=e;this.httpStatus=s;this.name="BotWireError"}},l=class{constructor(t={}){this._sessionToken=null;this.endpoint=(t.endpoint??m).replace(/\/+$/,""),this.publicKey=t.publicKey;let e=t.fetch??globalThis.fetch;if(!e)throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");this._fetch=e.bind(globalThis)}getSessionToken(){return this._sessionToken}setSessionToken(t){this._sessionToken=t}async initSession(t){let e=await this.post(`${this.endpoint}/session`,{},t);if(!e.ok)throw await this.toError(e);let i=await e.json();return this._sessionToken=i.sessionToken,{sessionToken:i.sessionToken,needsName:i.needsName??!1,errorMessage:i.errorMessage}}async chat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal);await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal));let s;try{s=await i.json()}catch{throw await this.toError(i)}return s.sessionToken&&(this._sessionToken=s.sessionToken),s}async*streamChat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal);if(await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal)),!i.ok||!i.body)throw await this.toError(i);yield*this.parseSse(i.body)}async ensureSession(t){this._sessionToken||await this.initSession(t)}body(t,e){let i={message:t,sessionToken:this._sessionToken};return e.contactEmail&&(i.contactEmail=e.contactEmail),i}async staleSession(t){if(t.status!==400)return!1;try{if((await t.clone().json()).status==="InvalidSession")return this._sessionToken=null,!0}catch{}return!1}async*parseSse(t){let e=t.getReader(),i=new TextDecoder,s="";try{for(;;){let{value:a,done:r}=await e.read();if(r)break;s+=i.decode(a,{stream:!0});let c;for(;(c=s.indexOf(`
|
|
2
|
+
`))!==-1;){let p=s.slice(0,c);if(s=s.slice(c+1),!p.startsWith("data: "))continue;let u=p.slice(6);if(u==="[DONE]"){yield{type:"done"};return}let g=x(u);g&&(yield g)}}}finally{e.releaseLock()}}post(t,e,i){let s={"Content-Type":"application/json"};return this.publicKey&&(s["X-BotWire-Key"]=this.publicKey),this._fetch(t,{method:"POST",headers:s,body:JSON.stringify(e),signal:i})}async toError(t){let e="Error",i=`BotWire request failed (HTTP ${t.status})`;try{let s=await t.clone().json();s.status&&(e=s.status),s.message&&(i=s.message)}catch{}return new o(e,i,t.status)}};function x(n){let t;try{t=JSON.parse(n)}catch{return null}switch(t.type){case"token":return{type:"delta",delta:t.value??""};case"collect_contact":return{type:"collect_contact"};case"escalated":return{type:"escalated",ticketId:t.ticketId??"",message:t.message??""};case"blocked":return{type:"blocked",reason:t.reason??""};default:return null}}var d="botwire_session",f={en:{title:"Support",greeting:"How can we help you today?",placeholder:"Type a message\u2026",sendLabel:"Send",contactPrompt:"Please leave your email address so our team can follow up with you.",emailPlaceholder:"your@email.com",submitLabel:"Submit",cancelLabel:"Cancel",cancelMessage:"You have ended this conversation."},"zh-CN":{title:"\u5728\u7EBF\u5BA2\u670D",greeting:"\u8BF7\u95EE\u6709\u4EC0\u4E48\u53EF\u4EE5\u5E2E\u60A8\uFF1F",placeholder:"\u8F93\u5165\u6D88\u606F\u2026",sendLabel:"\u53D1\u9001",contactPrompt:"\u8BF7\u7559\u4E0B\u60A8\u7684\u90AE\u7BB1\uFF0C\u65B9\u4FBF\u6211\u4EEC\u7684\u56E2\u961F\u8DDF\u8FDB\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u63D0\u4EA4",cancelLabel:"\u53D6\u6D88",cancelMessage:"\u60A8\u5DF2\u7ED3\u675F\u672C\u6B21\u4F1A\u8BDD\u3002"},ja:{title:"\u30B5\u30DD\u30FC\u30C8",greeting:"\u3054\u7528\u4EF6\u3092\u304A\u77E5\u3089\u305B\u304F\u3060\u3055\u3044\u3002",placeholder:"\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u5165\u529B\u2026",sendLabel:"\u9001\u4FE1",contactPrompt:"\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\u3092\u3054\u8A18\u5165\u3044\u305F\u3060\u3051\u308C\u3070\u3001\u62C5\u5F53\u8005\u3088\u308A\u3054\u9023\u7D61\u3044\u305F\u3057\u307E\u3059\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u9001\u4FE1\u3059\u308B",cancelLabel:"\u30AD\u30E3\u30F3\u30BB\u30EB",cancelMessage:"\u3053\u306E\u4F1A\u8A71\u3092\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002"}};function v(n){if(!n)return"en";let t=n.toLowerCase();return t==="zh"||t.startsWith("zh-")||t.startsWith("zh_")?"zh-CN":t==="ja"||t.startsWith("ja-")||t.startsWith("ja_")?"ja":"en"}function y(n){return n.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}function w(n,t,e){let s=t==="bottom-left"?"left":"right";return`
|
|
3
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
4
|
+
|
|
5
|
+
#bubble{
|
|
6
|
+
position:fixed;bottom:24px;${s}:24px;
|
|
7
|
+
width:56px;height:56px;
|
|
8
|
+
background:${n};border:none;border-radius:50%;cursor:pointer;
|
|
9
|
+
display:flex;align-items:center;justify-content:center;
|
|
10
|
+
box-shadow:0 4px 20px rgba(0,0,0,.28);
|
|
11
|
+
transition:transform .2s ease,box-shadow .2s ease;
|
|
12
|
+
z-index:2147483646;color:#fff;
|
|
13
|
+
}
|
|
14
|
+
#bubble:hover{transform:scale(1.08);box-shadow:0 6px 24px rgba(0,0,0,.32)}
|
|
15
|
+
#bubble svg{width:26px;height:26px;fill:#fff;pointer-events:none;flex-shrink:0}
|
|
16
|
+
|
|
17
|
+
#panel{
|
|
18
|
+
position:fixed;bottom:96px;${s}:20px;
|
|
19
|
+
width:360px;min-height:400px;max-height:560px;
|
|
20
|
+
background:#fff;border-radius:16px;
|
|
21
|
+
box-shadow:0 8px 48px rgba(0,0,0,.18);
|
|
22
|
+
display:flex;flex-direction:column;overflow:hidden;
|
|
23
|
+
z-index:2147483646;
|
|
24
|
+
animation:bw-in .2s ease-out;
|
|
25
|
+
}
|
|
26
|
+
#panel[hidden]{display:none!important}
|
|
27
|
+
@keyframes bw-in{from{transform:scale(.92) translateY(8px);opacity:0}to{transform:none;opacity:1}}
|
|
28
|
+
|
|
29
|
+
#header{
|
|
30
|
+
display:flex;align-items:center;justify-content:space-between;gap:8px;
|
|
31
|
+
padding:14px 16px;background:${n};color:#fff;flex-shrink:0;
|
|
32
|
+
}
|
|
33
|
+
#header-title{font-weight:600;font-size:15px;flex:1}
|
|
34
|
+
#header-actions{display:flex;align-items:center;gap:2px}
|
|
35
|
+
#reset,#close{
|
|
36
|
+
background:none;border:none;color:#fff;cursor:pointer;
|
|
37
|
+
font-size:20px;line-height:1;padding:2px 6px;border-radius:6px;opacity:.75;
|
|
38
|
+
display:flex;align-items:center;
|
|
39
|
+
}
|
|
40
|
+
#reset[hidden]{display:none!important}
|
|
41
|
+
#reset svg{width:17px;height:17px;fill:#fff}
|
|
42
|
+
#reset:hover,#close:hover{opacity:1;background:rgba(255,255,255,.15)}
|
|
43
|
+
|
|
44
|
+
#messages{
|
|
45
|
+
flex:1;overflow-y:auto;padding:12px 12px 4px;
|
|
46
|
+
display:flex;flex-direction:column;gap:8px;
|
|
47
|
+
scroll-behavior:smooth;
|
|
48
|
+
}
|
|
49
|
+
#messages:empty::before{
|
|
50
|
+
content:'${y(e)}';
|
|
51
|
+
color:#94a3b8;font-size:13px;text-align:center;
|
|
52
|
+
margin:auto;padding:32px 16px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.msg{
|
|
56
|
+
max-width:82%;padding:10px 14px;border-radius:14px;
|
|
57
|
+
font-size:14px;line-height:1.55;word-break:break-word;
|
|
58
|
+
animation:msg-in .15s ease-out;
|
|
59
|
+
}
|
|
60
|
+
@keyframes msg-in{from{transform:translateY(5px);opacity:0}to{transform:none;opacity:1}}
|
|
61
|
+
.msg-user{align-self:flex-end;background:${n};color:#fff;border-bottom-right-radius:3px}
|
|
62
|
+
.msg-bot {align-self:flex-start;background:#f1f5f9;color:#1e293b;border-bottom-left-radius:3px}
|
|
63
|
+
.msg-sys {
|
|
64
|
+
align-self:center;background:#fef9c3;color:#854d0e;
|
|
65
|
+
font-size:13px;border-radius:8px;text-align:center;max-width:90%;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#typing{padding:6px 12px 8px;display:flex;gap:4px;align-items:center;flex-shrink:0}
|
|
69
|
+
#typing[hidden]{display:none!important}
|
|
70
|
+
#typing span{
|
|
71
|
+
width:7px;height:7px;background:#94a3b8;border-radius:50%;
|
|
72
|
+
animation:bw-dot .9s infinite ease-in-out;
|
|
73
|
+
}
|
|
74
|
+
#typing span:nth-child(2){animation-delay:.15s}
|
|
75
|
+
#typing span:nth-child(3){animation-delay:.3s}
|
|
76
|
+
@keyframes bw-dot{0%,80%,100%{transform:scale(.6);opacity:.4}40%{transform:scale(1);opacity:1}}
|
|
77
|
+
|
|
78
|
+
#starters{
|
|
79
|
+
display:flex;flex-wrap:wrap;gap:8px;
|
|
80
|
+
padding:4px 12px 12px;flex-shrink:0;
|
|
81
|
+
}
|
|
82
|
+
#starters[hidden]{display:none!important}
|
|
83
|
+
.starter{
|
|
84
|
+
background:#f1f5f9;color:#334155;border:1px solid #e2e8f0;border-radius:999px;
|
|
85
|
+
padding:7px 14px;cursor:pointer;font:inherit;font-size:13px;
|
|
86
|
+
transition:background .15s,border-color .15s;
|
|
87
|
+
}
|
|
88
|
+
.starter:hover{background:#e2e8f0;border-color:#cbd5e1}
|
|
89
|
+
|
|
90
|
+
#input-area{
|
|
91
|
+
display:flex;gap:8px;align-items:flex-end;
|
|
92
|
+
padding:10px 12px;border-top:1px solid #e2e8f0;flex-shrink:0;
|
|
93
|
+
}
|
|
94
|
+
#input-area[hidden]{display:none!important}
|
|
95
|
+
#input{
|
|
96
|
+
flex:1;resize:none;border:1px solid #e2e8f0;border-radius:10px;
|
|
97
|
+
padding:8px 12px;font:inherit;font-size:14px;outline:none;
|
|
98
|
+
max-height:100px;overflow-y:auto;line-height:1.5;
|
|
99
|
+
transition:border-color .15s;
|
|
100
|
+
}
|
|
101
|
+
#input:focus{border-color:${n};outline:none}
|
|
102
|
+
#send{
|
|
103
|
+
background:${n};color:#fff;border:none;border-radius:10px;
|
|
104
|
+
padding:9px 16px;cursor:pointer;font-size:14px;font-weight:500;
|
|
105
|
+
white-space:nowrap;flex-shrink:0;transition:opacity .15s;
|
|
106
|
+
}
|
|
107
|
+
#send:disabled{opacity:.45;cursor:not-allowed}
|
|
108
|
+
#send:not(:disabled):hover{opacity:.88}
|
|
109
|
+
|
|
110
|
+
#contact-form{
|
|
111
|
+
padding:14px 16px 16px;border-top:1px solid #e2e8f0;
|
|
112
|
+
display:flex;flex-direction:column;gap:10px;flex-shrink:0;
|
|
113
|
+
}
|
|
114
|
+
#contact-form[hidden]{display:none!important}
|
|
115
|
+
#contact-form p{font-size:13px;color:#64748b;line-height:1.5}
|
|
116
|
+
#email-input{
|
|
117
|
+
border:1px solid #e2e8f0;border-radius:10px;
|
|
118
|
+
padding:9px 12px;font:inherit;font-size:14px;outline:none;
|
|
119
|
+
transition:border-color .15s;
|
|
120
|
+
}
|
|
121
|
+
#email-input:focus{border-color:${n}}
|
|
122
|
+
#contact-buttons{display:flex;gap:8px}
|
|
123
|
+
#contact-submit{
|
|
124
|
+
flex:1;background:${n};color:#fff;border:none;border-radius:10px;
|
|
125
|
+
padding:10px;cursor:pointer;font-size:14px;font-weight:500;
|
|
126
|
+
transition:opacity .15s;
|
|
127
|
+
}
|
|
128
|
+
#contact-submit:hover{opacity:.88}
|
|
129
|
+
#contact-cancel{
|
|
130
|
+
flex:1;background:#f1f5f9;color:#64748b;border:none;border-radius:10px;
|
|
131
|
+
padding:10px;cursor:pointer;font-size:14px;font-weight:500;
|
|
132
|
+
transition:background .15s;
|
|
133
|
+
}
|
|
134
|
+
#contact-cancel:hover{background:#e2e8f0}
|
|
135
|
+
|
|
136
|
+
#ticket-card{
|
|
137
|
+
margin:12px 12px 14px;padding:14px 16px;
|
|
138
|
+
background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;
|
|
139
|
+
font-size:13px;color:#166534;text-align:center;line-height:1.55;
|
|
140
|
+
flex-shrink:0;
|
|
141
|
+
}
|
|
142
|
+
#ticket-card[hidden]{display:none!important}
|
|
143
|
+
|
|
144
|
+
@media(max-width:480px){
|
|
145
|
+
#panel{bottom:0;left:0;right:0;width:100%;min-height:unset;max-height:100%;
|
|
146
|
+
border-radius:0;border-top-left-radius:16px;border-top-right-radius:16px}
|
|
147
|
+
#bubble{bottom:16px;${s}:16px}
|
|
148
|
+
}
|
|
149
|
+
`}var b='<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>',k='<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2zM6 10h12v2H6v-2zm0-4h12v2H6V6zm8 8H6v-2h8v2z"/></svg>',E='<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.65 6.35A7.96 7.96 0 0012 4a8 8 0 108 8h-2a6 6 0 11-1.76-4.24L13 11h7V4l-2.35 2.35z"/></svg>',h=class extends HTMLElement{constructor(){super();this.streaming=!1;this.streamAbort=null;this.awaitingEmail=!1;this.ticketCreated=!1;this.errorOccurred=!1;this.errorMessage="Something went wrong. Please try again.";this.shadow=this.attachShadow({mode:"open"})}get endpoint(){return this.dataset.endpoint??"/support"}get primary(){return this.dataset.primaryColor??"#6366f1"}get position(){return this.dataset.position??"bottom-right"}get publicKey(){return this.dataset.publicKey}get offtopicMessage(){return this.dataset.offtopicMessage}get resetEnabled(){return this.dataset.reset!=="false"}get resetConfirm(){return this.dataset.resetConfirm!=="false"}get langKey(){return v(this.dataset.lang)}t(e){let i=this.dataset[e];return i!==void 0?i:f[this.langKey]?.[e]??f.en[e]}get widgetTitle(){return this.t("title")}get placeholder(){return this.t("placeholder")}get contactPrompt(){return this.t("contactPrompt")}get emailPlaceholder(){return this.t("emailPlaceholder")}get sendLabel(){return this.t("sendLabel")}get submitLabel(){return this.t("submitLabel")}get cancelLabel(){return this.t("cancelLabel")}get cancelMessage(){return this.t("cancelMessage")}get greeting(){return this.t("greeting")}get starters(){return(this.dataset.starters??"").split("|").map(e=>e.trim()).filter(e=>e.length>0)}connectedCallback(){this.mount(),this.client=new l({endpoint:this.endpoint,publicKey:this.publicKey}),this.client.setSessionToken(sessionStorage.getItem(d)),this.client.getSessionToken()||this.initSession()}mount(){this.shadow.innerHTML=`
|
|
150
|
+
<style>${w(this.primary,this.position,this.greeting)}</style>
|
|
151
|
+
<button id="bubble" aria-label="Open support chat" aria-expanded="false">${b}</button>
|
|
152
|
+
<div id="panel" hidden role="dialog" aria-label="${this.esc(this.widgetTitle)} support chat">
|
|
153
|
+
<div id="header">
|
|
154
|
+
<span id="header-title">${this.esc(this.widgetTitle)}</span>
|
|
155
|
+
<div id="header-actions">
|
|
156
|
+
<button id="reset" type="button" hidden aria-label="Reset conversation">${E}</button>
|
|
157
|
+
<button id="close" aria-label="Close chat">\u2715</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div id="messages" role="log" aria-live="polite" aria-relevant="additions"></div>
|
|
161
|
+
<div id="starters" hidden></div>
|
|
162
|
+
<div id="typing" hidden aria-hidden="true"><span></span><span></span><span></span></div>
|
|
163
|
+
<div id="input-area">
|
|
164
|
+
<textarea id="input" placeholder="${this.esc(this.placeholder)}" rows="1" aria-label="Message input"></textarea>
|
|
165
|
+
<button id="send" type="button">${this.esc(this.sendLabel)}</button>
|
|
166
|
+
</div>
|
|
167
|
+
<form id="contact-form" hidden>
|
|
168
|
+
<p>${this.esc(this.contactPrompt)}</p>
|
|
169
|
+
<input type="email" id="email-input" placeholder="${this.esc(this.emailPlaceholder)}" required aria-label="Email address">
|
|
170
|
+
<div id="contact-buttons">
|
|
171
|
+
<button id="contact-submit" type="submit">${this.esc(this.submitLabel)}</button>
|
|
172
|
+
<button id="contact-cancel" type="button">${this.esc(this.cancelLabel)}</button>
|
|
173
|
+
</div>
|
|
174
|
+
</form>
|
|
175
|
+
<div id="ticket-card" hidden role="status"></div>
|
|
176
|
+
</div>`,this.panel=this.q("#panel"),this.bubble=this.q("#bubble"),this.messages=this.q("#messages"),this.startersBox=this.q("#starters"),this.resetBtn=this.q("#reset"),this.typing=this.q("#typing"),this.inputArea=this.q("#input-area"),this.input=this.q("#input"),this.sendBtn=this.q("#send"),this.contact=this.q("#contact-form"),this.emailIn=this.q("#email-input"),this.cancelBtn=this.q("#contact-cancel"),this.ticket=this.q("#ticket-card"),this.bubble.addEventListener("click",()=>this.toggle()),this.q("#close").addEventListener("click",()=>this.close()),this.resetBtn.addEventListener("click",()=>this.handleReset()),this.sendBtn.addEventListener("click",()=>this.handleSend()),this.input.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),this.handleSend())}),this.input.addEventListener("input",()=>this.autoResize()),this.contact.addEventListener("submit",e=>{e.preventDefault(),this.handleContactSubmit()}),this.cancelBtn.addEventListener("click",()=>this.handleContactCancel()),this.resetBtn.hidden=!this.resetEnabled,this.renderStarters()}async initSession(){try{let e=await this.client.initSession();sessionStorage.setItem(d,e.sessionToken),e.errorMessage&&(this.errorMessage=e.errorMessage)}catch{}}toggle(){this.panel.hidden?this.open():this.ticketCreated?(this.resetConversation(),this.input.focus()):this.close()}open(){this.ticketCreated&&this.resetConversation(),this.panel.hidden=!1,this.bubble.innerHTML=k,this.bubble.setAttribute("aria-expanded","true"),this.awaitingEmail?this.emailIn.focus():this.input.focus()}resetConversation(){this.streamAbort?.abort(),this.streamAbort=null,this.messages.innerHTML="",this.ticketCreated=!1,this.awaitingEmail=!1,this.streaming=!1,this.errorOccurred=!1,this.contact.hidden=!0,this.ticket.hidden=!0,this.inputArea.hidden=!1,this.sendBtn.disabled=!1,this.emailIn.value="",this.client.setSessionToken(null),sessionStorage.removeItem(d),this.renderStarters(),this.initSession()}handleReset(){if(this.resetConfirm){let e=this.dataset.resetConfirmMessage??"Start a new conversation?";if(typeof confirm=="function"&&!confirm(e))return}this.resetConversation(),!this.panel.hidden&&!this.awaitingEmail&&this.input.focus()}renderStarters(){this.startersBox.innerHTML="";let e=this.starters;if(e.length===0){this.startersBox.hidden=!0;return}for(let i of e){let s=document.createElement("button");s.type="button",s.className="starter",s.textContent=i,s.addEventListener("click",()=>{this.input.value=i,this.handleSend()}),this.startersBox.appendChild(s)}this.startersBox.hidden=!1}close(){this.errorOccurred&&this.resetConversation(),this.panel.hidden=!0,this.bubble.innerHTML=b,this.bubble.setAttribute("aria-expanded","false")}handleSend(){if(this.streaming||this.awaitingEmail)return;let e=this.input.value.trim();e&&(this.input.value="",this.autoResize(),this.startersBox.hidden=!0,this.appendMessage("user",e),this.stream(e))}handleContactSubmit(){let e=this.emailIn.value.trim();e&&(this.contact.hidden=!0,this.awaitingEmail=!1,this.stream("",e))}handleContactCancel(){this.contact.hidden=!0,this.awaitingEmail=!1,this.ticketCreated=!0,this.appendMessage("sys",this.cancelMessage)}async stream(e,i){if(this.streaming)return;this.streaming=!0,this.sendBtn.disabled=!0,this.typing.hidden=!1;let s=new AbortController;this.streamAbort=s;let a=null;try{for await(let r of this.client.streamChat(e,{contactEmail:i,signal:s.signal}))switch(this.typing.hidden=!0,r.type){case"delta":a||(a=this.appendMessage("bot","")),a.textContent+=r.delta,this.scrollBottom();break;case"collect_contact":this.inputArea.hidden=!0,this.contact.hidden=!1,this.awaitingEmail=!0,requestAnimationFrame(()=>{this.scrollBottom(),this.emailIn.focus()});break;case"escalated":this.ticketCreated=!0,this.ticket.hidden=!1,this.ticket.textContent=r.message,this.inputArea.hidden=!0;break;case"blocked":this.appendMessage("bot",this.offtopicMessage??r.reason);break;case"done":break}}catch(r){if(s.signal.aborted)return;this.typing.hidden=!0,r instanceof DOMException&&r.name==="AbortError"||(r instanceof o?(this.errorOccurred=!0,this.appendMessage("sys",this.errorMessage)):this.appendMessage("sys","Connection error. Please try again."))}finally{if(this.streamAbort===s){this.streamAbort=null;let r=this.client.getSessionToken();r&&sessionStorage.setItem(d,r),this.typing.hidden=!0,this.streaming=!1,!this.awaitingEmail&&!this.ticketCreated&&(this.sendBtn.disabled=!1,this.panel.hidden||this.input.focus())}}}appendMessage(e,i){let s=document.createElement("div");return s.className=`msg msg-${e}`,s.textContent=i,this.messages.appendChild(s),this.scrollBottom(),s}scrollBottom(){this.messages.scrollTop=this.messages.scrollHeight}autoResize(){this.input.style.height="auto",this.input.style.height=`${Math.min(this.input.scrollHeight,100)}px`}esc(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}q(e){return this.shadow.querySelector(e)}};customElements.define("botwire-widget",h);})();
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { BotWireConfig, BotWireEvent, BotWireResponse, ChatOptions, InitSessionResult } from './types.js';
|
|
2
|
+
/** Thrown when a request fails at the transport level or the server returns a non-OK status. */
|
|
3
|
+
export declare class BotWireError extends Error {
|
|
4
|
+
/** Server status string, or `Error` when none was returned. */
|
|
5
|
+
readonly status: string;
|
|
6
|
+
/** HTTP status code. */
|
|
7
|
+
readonly httpStatus: number;
|
|
8
|
+
constructor(
|
|
9
|
+
/** Server status string, or `Error` when none was returned. */
|
|
10
|
+
status: string, message: string,
|
|
11
|
+
/** HTTP status code. */
|
|
12
|
+
httpStatus: number);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Framework-agnostic client for the BotWire support endpoints.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* const client = new BotWireClient({ endpoint: '/support' });
|
|
19
|
+
* for await (const e of client.streamChat('How do refunds work?')) {
|
|
20
|
+
* if (e.type === 'delta') output += e.delta;
|
|
21
|
+
* if (e.type === 'escalated') showTicket(e.ticketId);
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare class BotWireClient {
|
|
26
|
+
private readonly endpoint;
|
|
27
|
+
private readonly publicKey?;
|
|
28
|
+
private readonly _fetch;
|
|
29
|
+
private _sessionToken;
|
|
30
|
+
constructor(config?: BotWireConfig);
|
|
31
|
+
/** The current session token, or `null` before the first session is created. */
|
|
32
|
+
getSessionToken(): string | null;
|
|
33
|
+
/** Restore or override the session token (e.g. one persisted from a previous visit). */
|
|
34
|
+
setSessionToken(token: string | null): void;
|
|
35
|
+
/** Create a fresh server session and adopt its token. */
|
|
36
|
+
initSession(signal?: AbortSignal): Promise<InitSessionResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Non-streaming chat. Resolves with the full response; inspect `status` to
|
|
39
|
+
* branch (e.g. `NeedHuman`, `TicketCreated`, `Blocked`). Creates a session if
|
|
40
|
+
* none exists and retries once on a stale token.
|
|
41
|
+
*/
|
|
42
|
+
chat(message: string, opts?: ChatOptions): Promise<BotWireResponse>;
|
|
43
|
+
/**
|
|
44
|
+
* Streaming chat. Yields {@link BotWireEvent}s until a terminal `done` event.
|
|
45
|
+
* Creates a session if none exists and retries once on a stale token.
|
|
46
|
+
*/
|
|
47
|
+
streamChat(message: string, opts?: ChatOptions): AsyncGenerator<BotWireEvent, void, unknown>;
|
|
48
|
+
private ensureSession;
|
|
49
|
+
private body;
|
|
50
|
+
/** A 400 with `{ status: 'InvalidSession' }` means the server dropped our token. */
|
|
51
|
+
private staleSession;
|
|
52
|
+
private parseSse;
|
|
53
|
+
private post;
|
|
54
|
+
private toError;
|
|
55
|
+
}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
BotWireClient: () => BotWireClient,
|
|
24
|
+
BotWireError: () => BotWireError
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(index_exports);
|
|
27
|
+
|
|
28
|
+
// src/client.ts
|
|
29
|
+
var DEFAULT_ENDPOINT = "/support";
|
|
30
|
+
var BotWireError = class extends Error {
|
|
31
|
+
constructor(status, message, httpStatus) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.httpStatus = httpStatus;
|
|
35
|
+
this.name = "BotWireError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var BotWireClient = class {
|
|
39
|
+
constructor(config = {}) {
|
|
40
|
+
this._sessionToken = null;
|
|
41
|
+
this.endpoint = (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
42
|
+
this.publicKey = config.publicKey;
|
|
43
|
+
const f = config.fetch ?? globalThis.fetch;
|
|
44
|
+
if (!f) throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");
|
|
45
|
+
this._fetch = f.bind(globalThis);
|
|
46
|
+
}
|
|
47
|
+
/** The current session token, or `null` before the first session is created. */
|
|
48
|
+
getSessionToken() {
|
|
49
|
+
return this._sessionToken;
|
|
50
|
+
}
|
|
51
|
+
/** Restore or override the session token (e.g. one persisted from a previous visit). */
|
|
52
|
+
setSessionToken(token) {
|
|
53
|
+
this._sessionToken = token;
|
|
54
|
+
}
|
|
55
|
+
/** Create a fresh server session and adopt its token. */
|
|
56
|
+
async initSession(signal) {
|
|
57
|
+
const resp = await this.post(`${this.endpoint}/session`, {}, signal);
|
|
58
|
+
if (!resp.ok) throw await this.toError(resp);
|
|
59
|
+
const data = await resp.json();
|
|
60
|
+
this._sessionToken = data.sessionToken;
|
|
61
|
+
return {
|
|
62
|
+
sessionToken: data.sessionToken,
|
|
63
|
+
needsName: data.needsName ?? false,
|
|
64
|
+
errorMessage: data.errorMessage
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Non-streaming chat. Resolves with the full response; inspect `status` to
|
|
69
|
+
* branch (e.g. `NeedHuman`, `TicketCreated`, `Blocked`). Creates a session if
|
|
70
|
+
* none exists and retries once on a stale token.
|
|
71
|
+
*/
|
|
72
|
+
async chat(message, opts = {}) {
|
|
73
|
+
await this.ensureSession(opts.signal);
|
|
74
|
+
let resp = await this.post(`${this.endpoint}/chat`, this.body(message, opts), opts.signal);
|
|
75
|
+
if (await this.staleSession(resp)) {
|
|
76
|
+
await this.initSession(opts.signal);
|
|
77
|
+
resp = await this.post(`${this.endpoint}/chat`, this.body(message, opts), opts.signal);
|
|
78
|
+
}
|
|
79
|
+
let data;
|
|
80
|
+
try {
|
|
81
|
+
data = await resp.json();
|
|
82
|
+
} catch {
|
|
83
|
+
throw await this.toError(resp);
|
|
84
|
+
}
|
|
85
|
+
if (data.sessionToken) this._sessionToken = data.sessionToken;
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Streaming chat. Yields {@link BotWireEvent}s until a terminal `done` event.
|
|
90
|
+
* Creates a session if none exists and retries once on a stale token.
|
|
91
|
+
*/
|
|
92
|
+
async *streamChat(message, opts = {}) {
|
|
93
|
+
await this.ensureSession(opts.signal);
|
|
94
|
+
let resp = await this.post(`${this.endpoint}/chat/stream`, this.body(message, opts), opts.signal);
|
|
95
|
+
if (await this.staleSession(resp)) {
|
|
96
|
+
await this.initSession(opts.signal);
|
|
97
|
+
resp = await this.post(`${this.endpoint}/chat/stream`, this.body(message, opts), opts.signal);
|
|
98
|
+
}
|
|
99
|
+
if (!resp.ok || !resp.body) throw await this.toError(resp);
|
|
100
|
+
yield* this.parseSse(resp.body);
|
|
101
|
+
}
|
|
102
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
103
|
+
async ensureSession(signal) {
|
|
104
|
+
if (!this._sessionToken) await this.initSession(signal);
|
|
105
|
+
}
|
|
106
|
+
body(message, opts) {
|
|
107
|
+
const b = { message, sessionToken: this._sessionToken };
|
|
108
|
+
if (opts.contactEmail) b["contactEmail"] = opts.contactEmail;
|
|
109
|
+
return b;
|
|
110
|
+
}
|
|
111
|
+
/** A 400 with `{ status: 'InvalidSession' }` means the server dropped our token. */
|
|
112
|
+
async staleSession(resp) {
|
|
113
|
+
if (resp.status !== 400) return false;
|
|
114
|
+
try {
|
|
115
|
+
const data = await resp.clone().json();
|
|
116
|
+
if (data.status === "InvalidSession") {
|
|
117
|
+
this._sessionToken = null;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
async *parseSse(stream) {
|
|
125
|
+
const reader = stream.getReader();
|
|
126
|
+
const decoder = new TextDecoder();
|
|
127
|
+
let buf = "";
|
|
128
|
+
try {
|
|
129
|
+
while (true) {
|
|
130
|
+
const { value, done } = await reader.read();
|
|
131
|
+
if (done) break;
|
|
132
|
+
buf += decoder.decode(value, { stream: true });
|
|
133
|
+
let nl;
|
|
134
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
135
|
+
const line = buf.slice(0, nl);
|
|
136
|
+
buf = buf.slice(nl + 1);
|
|
137
|
+
if (!line.startsWith("data: ")) continue;
|
|
138
|
+
const data = line.slice(6);
|
|
139
|
+
if (data === "[DONE]") {
|
|
140
|
+
yield { type: "done" };
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const evt = mapWireEvent(data);
|
|
144
|
+
if (evt) yield evt;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
reader.releaseLock();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
post(url, body, signal) {
|
|
152
|
+
const headers = { "Content-Type": "application/json" };
|
|
153
|
+
if (this.publicKey) headers["X-BotWire-Key"] = this.publicKey;
|
|
154
|
+
return this._fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
155
|
+
}
|
|
156
|
+
async toError(resp) {
|
|
157
|
+
let status = "Error";
|
|
158
|
+
let message = `BotWire request failed (HTTP ${resp.status})`;
|
|
159
|
+
try {
|
|
160
|
+
const d = await resp.clone().json();
|
|
161
|
+
if (d.status) status = d.status;
|
|
162
|
+
if (d.message) message = d.message;
|
|
163
|
+
} catch {
|
|
164
|
+
}
|
|
165
|
+
return new BotWireError(status, message, resp.status);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
function mapWireEvent(data) {
|
|
169
|
+
let w;
|
|
170
|
+
try {
|
|
171
|
+
w = JSON.parse(data);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
switch (w.type) {
|
|
176
|
+
case "token":
|
|
177
|
+
return { type: "delta", delta: w.value ?? "" };
|
|
178
|
+
case "collect_contact":
|
|
179
|
+
return { type: "collect_contact" };
|
|
180
|
+
case "escalated":
|
|
181
|
+
return { type: "escalated", ticketId: w.ticketId ?? "", message: w.message ?? "" };
|
|
182
|
+
case "blocked":
|
|
183
|
+
return { type: "blocked", reason: w.reason ?? "" };
|
|
184
|
+
default:
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/client.ts
|
|
2
|
+
var DEFAULT_ENDPOINT = "/support";
|
|
3
|
+
var BotWireError = class extends Error {
|
|
4
|
+
constructor(status, message, httpStatus) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.httpStatus = httpStatus;
|
|
8
|
+
this.name = "BotWireError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var BotWireClient = class {
|
|
12
|
+
constructor(config = {}) {
|
|
13
|
+
this._sessionToken = null;
|
|
14
|
+
this.endpoint = (config.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
15
|
+
this.publicKey = config.publicKey;
|
|
16
|
+
const f = config.fetch ?? globalThis.fetch;
|
|
17
|
+
if (!f) throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");
|
|
18
|
+
this._fetch = f.bind(globalThis);
|
|
19
|
+
}
|
|
20
|
+
/** The current session token, or `null` before the first session is created. */
|
|
21
|
+
getSessionToken() {
|
|
22
|
+
return this._sessionToken;
|
|
23
|
+
}
|
|
24
|
+
/** Restore or override the session token (e.g. one persisted from a previous visit). */
|
|
25
|
+
setSessionToken(token) {
|
|
26
|
+
this._sessionToken = token;
|
|
27
|
+
}
|
|
28
|
+
/** Create a fresh server session and adopt its token. */
|
|
29
|
+
async initSession(signal) {
|
|
30
|
+
const resp = await this.post(`${this.endpoint}/session`, {}, signal);
|
|
31
|
+
if (!resp.ok) throw await this.toError(resp);
|
|
32
|
+
const data = await resp.json();
|
|
33
|
+
this._sessionToken = data.sessionToken;
|
|
34
|
+
return {
|
|
35
|
+
sessionToken: data.sessionToken,
|
|
36
|
+
needsName: data.needsName ?? false,
|
|
37
|
+
errorMessage: data.errorMessage
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Non-streaming chat. Resolves with the full response; inspect `status` to
|
|
42
|
+
* branch (e.g. `NeedHuman`, `TicketCreated`, `Blocked`). Creates a session if
|
|
43
|
+
* none exists and retries once on a stale token.
|
|
44
|
+
*/
|
|
45
|
+
async chat(message, opts = {}) {
|
|
46
|
+
await this.ensureSession(opts.signal);
|
|
47
|
+
let resp = await this.post(`${this.endpoint}/chat`, this.body(message, opts), opts.signal);
|
|
48
|
+
if (await this.staleSession(resp)) {
|
|
49
|
+
await this.initSession(opts.signal);
|
|
50
|
+
resp = await this.post(`${this.endpoint}/chat`, this.body(message, opts), opts.signal);
|
|
51
|
+
}
|
|
52
|
+
let data;
|
|
53
|
+
try {
|
|
54
|
+
data = await resp.json();
|
|
55
|
+
} catch {
|
|
56
|
+
throw await this.toError(resp);
|
|
57
|
+
}
|
|
58
|
+
if (data.sessionToken) this._sessionToken = data.sessionToken;
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Streaming chat. Yields {@link BotWireEvent}s until a terminal `done` event.
|
|
63
|
+
* Creates a session if none exists and retries once on a stale token.
|
|
64
|
+
*/
|
|
65
|
+
async *streamChat(message, opts = {}) {
|
|
66
|
+
await this.ensureSession(opts.signal);
|
|
67
|
+
let resp = await this.post(`${this.endpoint}/chat/stream`, this.body(message, opts), opts.signal);
|
|
68
|
+
if (await this.staleSession(resp)) {
|
|
69
|
+
await this.initSession(opts.signal);
|
|
70
|
+
resp = await this.post(`${this.endpoint}/chat/stream`, this.body(message, opts), opts.signal);
|
|
71
|
+
}
|
|
72
|
+
if (!resp.ok || !resp.body) throw await this.toError(resp);
|
|
73
|
+
yield* this.parseSse(resp.body);
|
|
74
|
+
}
|
|
75
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
76
|
+
async ensureSession(signal) {
|
|
77
|
+
if (!this._sessionToken) await this.initSession(signal);
|
|
78
|
+
}
|
|
79
|
+
body(message, opts) {
|
|
80
|
+
const b = { message, sessionToken: this._sessionToken };
|
|
81
|
+
if (opts.contactEmail) b["contactEmail"] = opts.contactEmail;
|
|
82
|
+
return b;
|
|
83
|
+
}
|
|
84
|
+
/** A 400 with `{ status: 'InvalidSession' }` means the server dropped our token. */
|
|
85
|
+
async staleSession(resp) {
|
|
86
|
+
if (resp.status !== 400) return false;
|
|
87
|
+
try {
|
|
88
|
+
const data = await resp.clone().json();
|
|
89
|
+
if (data.status === "InvalidSession") {
|
|
90
|
+
this._sessionToken = null;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
async *parseSse(stream) {
|
|
98
|
+
const reader = stream.getReader();
|
|
99
|
+
const decoder = new TextDecoder();
|
|
100
|
+
let buf = "";
|
|
101
|
+
try {
|
|
102
|
+
while (true) {
|
|
103
|
+
const { value, done } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
buf += decoder.decode(value, { stream: true });
|
|
106
|
+
let nl;
|
|
107
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
108
|
+
const line = buf.slice(0, nl);
|
|
109
|
+
buf = buf.slice(nl + 1);
|
|
110
|
+
if (!line.startsWith("data: ")) continue;
|
|
111
|
+
const data = line.slice(6);
|
|
112
|
+
if (data === "[DONE]") {
|
|
113
|
+
yield { type: "done" };
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const evt = mapWireEvent(data);
|
|
117
|
+
if (evt) yield evt;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
reader.releaseLock();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
post(url, body, signal) {
|
|
125
|
+
const headers = { "Content-Type": "application/json" };
|
|
126
|
+
if (this.publicKey) headers["X-BotWire-Key"] = this.publicKey;
|
|
127
|
+
return this._fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
|
|
128
|
+
}
|
|
129
|
+
async toError(resp) {
|
|
130
|
+
let status = "Error";
|
|
131
|
+
let message = `BotWire request failed (HTTP ${resp.status})`;
|
|
132
|
+
try {
|
|
133
|
+
const d = await resp.clone().json();
|
|
134
|
+
if (d.status) status = d.status;
|
|
135
|
+
if (d.message) message = d.message;
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
return new BotWireError(status, message, resp.status);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
function mapWireEvent(data) {
|
|
142
|
+
let w;
|
|
143
|
+
try {
|
|
144
|
+
w = JSON.parse(data);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
switch (w.type) {
|
|
149
|
+
case "token":
|
|
150
|
+
return { type: "delta", delta: w.value ?? "" };
|
|
151
|
+
case "collect_contact":
|
|
152
|
+
return { type: "collect_contact" };
|
|
153
|
+
case "escalated":
|
|
154
|
+
return { type: "escalated", ticketId: w.ticketId ?? "", message: w.message ?? "" };
|
|
155
|
+
case "blocked":
|
|
156
|
+
return { type: "blocked", reason: w.reason ?? "" };
|
|
157
|
+
default:
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export {
|
|
162
|
+
BotWireClient,
|
|
163
|
+
BotWireError
|
|
164
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Configuration for a {@link BotWireClient}. */
|
|
2
|
+
export interface BotWireConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Base API path or absolute URL the server mounted BotWire on, e.g. `/support`
|
|
5
|
+
* or `https://app.example.com/support`. The client appends `/session`, `/chat`
|
|
6
|
+
* and `/chat/stream`. Defaults to `/support`.
|
|
7
|
+
*/
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
/** Optional public key, sent as the `X-BotWire-Key` header on every request. */
|
|
10
|
+
publicKey?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Custom `fetch` implementation. Defaults to the global `fetch`. Provide one
|
|
13
|
+
* for environments without a global (older Node) or for testing.
|
|
14
|
+
*/
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
/** Result of a non-streaming {@link BotWireClient.chat} call. */
|
|
18
|
+
export interface BotWireResponse {
|
|
19
|
+
/** Server status, e.g. `Answered`, `NeedHuman`, `TicketCreated`, `Blocked`, `RateLimited`. */
|
|
20
|
+
status: string;
|
|
21
|
+
/** The assistant message (or status explanation). */
|
|
22
|
+
message: string;
|
|
23
|
+
/** Session token to reuse on the next turn. */
|
|
24
|
+
sessionToken: string;
|
|
25
|
+
/** Ticket id when an escalation produced a ticket. */
|
|
26
|
+
ticketId?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Result of {@link BotWireClient.initSession}. */
|
|
29
|
+
export interface InitSessionResult {
|
|
30
|
+
/** The new session token. */
|
|
31
|
+
sessionToken: string;
|
|
32
|
+
/** `true` when the server wants the user's name before answering. */
|
|
33
|
+
needsName: boolean;
|
|
34
|
+
/** Host-configured error message, surfaced for display on failures. */
|
|
35
|
+
errorMessage?: string;
|
|
36
|
+
}
|
|
37
|
+
/** A single event yielded by {@link BotWireClient.streamChat}. */
|
|
38
|
+
export type BotWireEvent =
|
|
39
|
+
/** A chunk of assistant text. Concatenate `delta`s to render the reply. */
|
|
40
|
+
{
|
|
41
|
+
type: 'delta';
|
|
42
|
+
delta: string;
|
|
43
|
+
}
|
|
44
|
+
/** The bot needs a contact email; resend via {@link ChatOptions.contactEmail}. */
|
|
45
|
+
| {
|
|
46
|
+
type: 'collect_contact';
|
|
47
|
+
}
|
|
48
|
+
/** The turn escalated to a human and a ticket was created. */
|
|
49
|
+
| {
|
|
50
|
+
type: 'escalated';
|
|
51
|
+
ticketId: string;
|
|
52
|
+
message: string;
|
|
53
|
+
}
|
|
54
|
+
/** The message was blocked (PII, length, prompt-injection, off-topic). */
|
|
55
|
+
| {
|
|
56
|
+
type: 'blocked';
|
|
57
|
+
reason: string;
|
|
58
|
+
}
|
|
59
|
+
/** Terminal event — the stream finished normally. */
|
|
60
|
+
| {
|
|
61
|
+
type: 'done';
|
|
62
|
+
};
|
|
63
|
+
/** Per-call options for {@link BotWireClient.chat} and {@link BotWireClient.streamChat}. */
|
|
64
|
+
export interface ChatOptions {
|
|
65
|
+
/** Contact email, supplied in response to a `collect_contact` event. */
|
|
66
|
+
contactEmail?: string;
|
|
67
|
+
/** Abort signal to cancel the request or stream. */
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "botwire-js",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Framework-agnostic JS/TS client for the BotWire support API — chat, SSE streaming, and session management with zero DOM dependencies.",
|
|
5
|
+
"license": "AGPL-3.0-or-later",
|
|
6
|
+
"author": "Object IT Limited",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/adamy/BotWire.git",
|
|
10
|
+
"directory": "npm/botwire-js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["botwire", "support", "chatbot", "sse", "streaming", "customer-support"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"require": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": ["dist", "README.md"],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build:sdk": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js && esbuild src/index.ts --bundle --format=cjs --outfile=dist/index.cjs && tsc -p tsconfig.build.json",
|
|
28
|
+
"build:widget": "tsc --noEmit && esbuild src/widget.ts --bundle --minify --outfile=dist/botwire.js",
|
|
29
|
+
"build:fast": "esbuild src/widget.ts --bundle --minify --outfile=dist/botwire.js",
|
|
30
|
+
"build": "npm run build:sdk && npm run build:widget",
|
|
31
|
+
"size": "esbuild src/widget.ts --bundle --minify | gzip -c | wc -c",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"prepublishOnly": "npm run build:sdk"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"esbuild": "^0.28.0",
|
|
37
|
+
"happy-dom": "^20.0.2",
|
|
38
|
+
"typescript": "^6.0.3",
|
|
39
|
+
"vitest": "^4.0.5"
|
|
40
|
+
}
|
|
41
|
+
}
|