@frontera-sdk/chat 1.52.45 → 1.52.46
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/dist/auth.d.ts +20 -0
- package/dist/auth.js +11 -0
- package/dist/callback-page.d.ts +49 -0
- package/dist/callback-page.js +84 -0
- package/dist/chat-client.d.ts +221 -0
- package/dist/chat-client.js +5 -0
- package/dist/chat-errors.d.ts +30 -0
- package/dist/chat-errors.js +3 -0
- package/dist/chunk-CMMWTCYS.js +116 -0
- package/dist/chunk-KZEGEV3P.js +35 -0
- package/dist/chunk-LS26G2N6.js +120 -0
- package/dist/chunk-RLHHRJWS.js +371 -0
- package/dist/chunk-UCUVHMJC.js +62 -0
- package/dist/chunk-VWUR3ECR.js +115 -0
- package/dist/chunk-W25Q2DQA.js +32 -0
- package/dist/chunk-YKQ5Z5EK.js +32 -0
- package/dist/connect-popup.d.ts +150 -0
- package/dist/connect-popup.js +138 -0
- package/dist/doctor.d.ts +71 -0
- package/dist/doctor.js +3 -0
- package/dist/element.d.ts +23 -0
- package/dist/element.js +175 -0
- package/dist/frontera-chat.iife.js +27 -0
- package/dist/hooks.d.ts +88 -0
- package/dist/hooks.js +3 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.js +13 -0
- package/dist/public-chat.d.ts +50 -0
- package/dist/public-chat.js +6 -0
- package/dist/public-token.d.ts +46 -0
- package/dist/public-token.js +3 -0
- package/dist/reply-text.d.ts +25 -0
- package/dist/reply-text.js +3 -0
- package/dist/stream.d.ts +25 -0
- package/dist/stream.js +3 -0
- package/dist/token-session.d.ts +66 -0
- package/dist/token-session.js +99 -0
- package/dist/transport.d.ts +60 -0
- package/dist/transport.js +103 -0
- package/dist/types.d.ts +361 -0
- package/dist/types.js +3 -0
- package/dist/ui-stream.d.ts +25 -0
- package/dist/ui-stream.js +26 -0
- package/package.json +70 -30
- package/src/chat-errors.ts +43 -0
- package/src/element.ts +201 -0
- package/src/frontera-chat.global.ts +13 -0
- package/src/public-chat.ts +154 -0
- package/src/public-token.ts +36 -4
- package/src/reply-text.ts +49 -0
- package/src/transport.ts +7 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The error codes a chat integration meets, and what each means for the
|
|
5
|
+
* person chatting (Forge improvement plan L4: the codes were found by reading
|
|
6
|
+
* the package source). Every SDK call throws `FronteraError`; branch on its
|
|
7
|
+
* `code`. The service can add codes, so treat one not listed here as
|
|
8
|
+
* `INTERNAL_ERROR`.
|
|
9
|
+
*/
|
|
10
|
+
export const CHAT_ERROR_CODES = {
|
|
11
|
+
UNAUTHORIZED: 'The key or token was not accepted: it is disabled, removed, mistyped or expired. Mint a new token; if that fails too, check the key.',
|
|
12
|
+
FORBIDDEN: 'The credential may not do this: the page is not on the key’s allowed websites, or a visitor asked for something a public chat cannot do.',
|
|
13
|
+
NOT_FOUND: 'The conversation or run is not this credential’s, or no longer exists.',
|
|
14
|
+
VALIDATION_ERROR: 'The request was malformed, for example an empty message or more than five messages.',
|
|
15
|
+
RATE_LIMITED: 'Too many messages or new chats in a short time. Wait the number of seconds the message names, then try again.',
|
|
16
|
+
QUOTA_EXCEEDED: 'The organization’s usage limit is reached. An administrator raises it.',
|
|
17
|
+
SERVICE_UNAVAILABLE: 'The platform can’t take the request right now. Try again shortly.',
|
|
18
|
+
INTERNAL_ERROR: 'Something failed on the platform. Try again; if it keeps failing, the message names what to report.',
|
|
19
|
+
PROTOCOL_ERROR: 'The response wasn’t a chat stream: the API origin is likely wrong, or a proxy changed the response.',
|
|
20
|
+
FORBIDDEN_LOCAL: 'Your own `authorizeConversation` callback denied access to the conversation.',
|
|
21
|
+
CONFIG_MISSING: 'The client has no API origin or credential.',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type ChatErrorCode = keyof typeof CHAT_ERROR_CODES
|
|
25
|
+
|
|
26
|
+
/** The code of any thrown value, `INTERNAL_ERROR` for one that isn't a `FronteraError` (a network failure included). */
|
|
27
|
+
export function chatErrorCode(err: unknown): ChatErrorCode {
|
|
28
|
+
const code = err instanceof FronteraError ? err.code : null
|
|
29
|
+
return code && code in CHAT_ERROR_CODES ? (code as ChatErrorCode) : 'INTERNAL_ERROR'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What to show a website visitor for an error: the platform's own message for
|
|
34
|
+
* a limit (it names the wait), plain words otherwise. Never the raw response.
|
|
35
|
+
*/
|
|
36
|
+
export function visitorErrorWords(err: unknown): string {
|
|
37
|
+
const code = chatErrorCode(err)
|
|
38
|
+
if (code === 'RATE_LIMITED' && err instanceof FronteraError) return err.message
|
|
39
|
+
if (code === 'UNAUTHORIZED') return 'This chat stopped working. Reload the page to start again.'
|
|
40
|
+
if (code === 'FORBIDDEN') return 'This chat isn’t available on this page.'
|
|
41
|
+
if (code === 'QUOTA_EXCEEDED' || code === 'SERVICE_UNAVAILABLE') return 'The assistant is unavailable right now. Please try again later.'
|
|
42
|
+
return 'The message couldn’t be sent. Please try again.'
|
|
43
|
+
}
|
package/src/element.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { visitorErrorWords } from './chat-errors'
|
|
2
|
+
import { PublicChat } from './public-chat'
|
|
3
|
+
import { replyParagraphs } from './reply-text'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `<frontera-chat>`: an Agent on any web page, with no framework (Forge
|
|
7
|
+
* improvement plan L4). It runs in its own shadow DOM, so the page's styles
|
|
8
|
+
* and its own never meet, fetches nothing but the chat, and shows the Agent's
|
|
9
|
+
* replies as plain text, bold and links: no work steps, no artifact viewers,
|
|
10
|
+
* and no citation links unless `show-sources` is set.
|
|
11
|
+
*
|
|
12
|
+
* <script src="…/frontera-chat.iife.js"></script>
|
|
13
|
+
* <frontera-chat api-base-url="https://api.acme.example" publishable-key="fpk_…"></frontera-chat>
|
|
14
|
+
*
|
|
15
|
+
* Attributes: `api-base-url` and `publishable-key` (required), `chat-title`,
|
|
16
|
+
* `placeholder`, `show-sources`. Style it with the custom properties in
|
|
17
|
+
* `STYLE` (`--frontera-chat-accent` and the rest). It dispatches
|
|
18
|
+
* `frontera-chat-error` with `{ code, message }` when a send fails.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const STYLE = `
|
|
22
|
+
:host { display: block; height: 100%; font: 14px/1.45 var(--frontera-chat-font, system-ui, sans-serif); color: var(--frontera-chat-text, #1b1b1f); }
|
|
23
|
+
.frame { display: flex; flex-direction: column; height: 100%; min-height: 320px; background: var(--frontera-chat-surface, #fff); border: 1px solid var(--frontera-chat-border, #e3e3e8); border-radius: var(--frontera-chat-radius, 12px); overflow: hidden; }
|
|
24
|
+
header { padding: 12px 16px; font-weight: 600; border-bottom: 1px solid var(--frontera-chat-border, #e3e3e8); }
|
|
25
|
+
.log { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
|
26
|
+
.msg { max-width: 85%; padding: 8px 12px; border-radius: 12px; white-space: normal; overflow-wrap: anywhere; }
|
|
27
|
+
.msg p { margin: 0 0 8px; } .msg p:last-child { margin: 0; }
|
|
28
|
+
.agent { align-self: flex-start; background: var(--frontera-chat-agent-bubble, #f1f1f4); }
|
|
29
|
+
.visitor { align-self: flex-end; background: var(--frontera-chat-accent, #2f5bea); color: var(--frontera-chat-accent-text, #fff); }
|
|
30
|
+
.note { align-self: center; font-size: 12px; color: var(--frontera-chat-muted, #6b6b76); }
|
|
31
|
+
.typing { align-self: flex-start; color: var(--frontera-chat-muted, #6b6b76); font-size: 12px; }
|
|
32
|
+
a { color: inherit; }
|
|
33
|
+
form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--frontera-chat-border, #e3e3e8); }
|
|
34
|
+
textarea { flex: 1; resize: none; font: inherit; padding: 8px 10px; border: 1px solid var(--frontera-chat-border, #e3e3e8); border-radius: 8px; min-height: 20px; max-height: 120px; }
|
|
35
|
+
button { font: inherit; padding: 0 14px; border: 0; border-radius: 8px; background: var(--frontera-chat-accent, #2f5bea); color: var(--frontera-chat-accent-text, #fff); cursor: pointer; }
|
|
36
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
// Safe to import where there is no DOM (server rendering): the class is only defined in a browser.
|
|
40
|
+
const Base = (typeof HTMLElement === 'undefined' ? class {} : HTMLElement) as typeof HTMLElement
|
|
41
|
+
|
|
42
|
+
export class FronteraChatElement extends Base {
|
|
43
|
+
static observedAttributes = ['chat-title', 'placeholder']
|
|
44
|
+
|
|
45
|
+
private chat: PublicChat | null = null
|
|
46
|
+
private log!: HTMLElement
|
|
47
|
+
private input!: HTMLTextAreaElement
|
|
48
|
+
private sendButton!: HTMLButtonElement
|
|
49
|
+
private header!: HTMLElement
|
|
50
|
+
private busy = false
|
|
51
|
+
|
|
52
|
+
connectedCallback(): void {
|
|
53
|
+
if (this.chat) return
|
|
54
|
+
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
|
|
55
|
+
const doc = this.ownerDocument
|
|
56
|
+
const style = doc.createElement('style')
|
|
57
|
+
style.textContent = STYLE
|
|
58
|
+
const frame = doc.createElement('div')
|
|
59
|
+
frame.className = 'frame'
|
|
60
|
+
this.header = doc.createElement('header')
|
|
61
|
+
this.log = doc.createElement('div')
|
|
62
|
+
this.log.className = 'log'
|
|
63
|
+
this.log.setAttribute('role', 'log')
|
|
64
|
+
this.log.setAttribute('aria-live', 'polite')
|
|
65
|
+
const form = doc.createElement('form')
|
|
66
|
+
this.input = doc.createElement('textarea')
|
|
67
|
+
this.input.rows = 1
|
|
68
|
+
this.input.setAttribute('aria-label', 'Message')
|
|
69
|
+
this.sendButton = doc.createElement('button')
|
|
70
|
+
this.sendButton.type = 'submit'
|
|
71
|
+
this.sendButton.textContent = 'Send'
|
|
72
|
+
form.append(this.input, this.sendButton)
|
|
73
|
+
frame.append(this.header, this.log, form)
|
|
74
|
+
root.replaceChildren(style, frame)
|
|
75
|
+
this.applyAttributes()
|
|
76
|
+
|
|
77
|
+
form.addEventListener('submit', (e) => {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
void this.submit()
|
|
80
|
+
})
|
|
81
|
+
this.input.addEventListener('keydown', (e) => {
|
|
82
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
void this.submit()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const apiBaseUrl = this.getAttribute('api-base-url')?.replace(/\/+$/, '')
|
|
89
|
+
const publishableKey = this.getAttribute('publishable-key')
|
|
90
|
+
if (!apiBaseUrl || !publishableKey) {
|
|
91
|
+
this.note('This chat is missing its api-base-url or publishable-key.')
|
|
92
|
+
this.setBusy(true)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
this.chat = new PublicChat({ apiBaseUrl, publishableKey })
|
|
96
|
+
this.chat
|
|
97
|
+
.open()
|
|
98
|
+
.then(({ openingMessage }) => {
|
|
99
|
+
if (openingMessage) this.agentBubble().replaceChildren(...this.render(openingMessage))
|
|
100
|
+
})
|
|
101
|
+
// The first send mints again and says what failed.
|
|
102
|
+
.catch(() => {})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
attributeChangedCallback(): void {
|
|
106
|
+
if (this.header) this.applyAttributes()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private applyAttributes(): void {
|
|
110
|
+
const title = this.getAttribute('chat-title')
|
|
111
|
+
this.header.textContent = title ?? ''
|
|
112
|
+
this.header.hidden = !title
|
|
113
|
+
this.input.placeholder = this.getAttribute('placeholder') ?? 'Type a message'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private setBusy(busy: boolean): void {
|
|
117
|
+
this.busy = busy
|
|
118
|
+
this.sendButton.disabled = busy
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private note(text: string): void {
|
|
122
|
+
const el = this.ownerDocument.createElement('div')
|
|
123
|
+
el.className = 'note'
|
|
124
|
+
el.textContent = text
|
|
125
|
+
this.log.append(el)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private agentBubble(): HTMLElement {
|
|
129
|
+
const el = this.ownerDocument.createElement('div')
|
|
130
|
+
el.className = 'msg agent'
|
|
131
|
+
this.log.append(el)
|
|
132
|
+
return el
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The reply as paragraphs of text, bold and links, built as nodes: nothing from the reply becomes HTML. */
|
|
136
|
+
private render(text: string): HTMLElement[] {
|
|
137
|
+
const doc = this.ownerDocument
|
|
138
|
+
return replyParagraphs(text, { showSources: this.hasAttribute('show-sources') }).map((lines) => {
|
|
139
|
+
const p = doc.createElement('p')
|
|
140
|
+
lines.forEach((pieces, i) => {
|
|
141
|
+
if (i > 0) p.append(doc.createElement('br'))
|
|
142
|
+
for (const piece of pieces) {
|
|
143
|
+
if (piece.kind === 'text') p.append(doc.createTextNode(piece.text))
|
|
144
|
+
else if (piece.kind === 'bold') {
|
|
145
|
+
const b = doc.createElement('strong')
|
|
146
|
+
b.textContent = piece.text
|
|
147
|
+
p.append(b)
|
|
148
|
+
} else {
|
|
149
|
+
const a = doc.createElement('a')
|
|
150
|
+
a.href = piece.href
|
|
151
|
+
a.textContent = piece.text
|
|
152
|
+
a.target = '_blank'
|
|
153
|
+
a.rel = 'noopener noreferrer'
|
|
154
|
+
p.append(a)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
return p
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async submit(): Promise<void> {
|
|
163
|
+
const text = this.input.value.trim()
|
|
164
|
+
if (!text || this.busy || !this.chat) return
|
|
165
|
+
this.input.value = ''
|
|
166
|
+
const mine = this.ownerDocument.createElement('div')
|
|
167
|
+
mine.className = 'msg visitor'
|
|
168
|
+
mine.textContent = text
|
|
169
|
+
this.log.append(mine)
|
|
170
|
+
this.setBusy(true)
|
|
171
|
+
const typing = this.ownerDocument.createElement('div')
|
|
172
|
+
typing.className = 'typing'
|
|
173
|
+
typing.textContent = 'Typing…'
|
|
174
|
+
let bubble: HTMLElement | null = null
|
|
175
|
+
try {
|
|
176
|
+
for await (const event of this.chat.send(text)) {
|
|
177
|
+
if (event.type === 'waiting') this.log.append(typing)
|
|
178
|
+
else if (event.type === 'text') {
|
|
179
|
+
typing.remove()
|
|
180
|
+
bubble ??= this.agentBubble()
|
|
181
|
+
bubble.replaceChildren(...this.render(event.text))
|
|
182
|
+
} else if (event.type === 'error') {
|
|
183
|
+
typing.remove()
|
|
184
|
+
this.note(visitorErrorWords(event.error))
|
|
185
|
+
this.dispatchEvent(new CustomEvent('frontera-chat-error', { detail: { code: event.error.code, message: event.error.message } }))
|
|
186
|
+
}
|
|
187
|
+
this.log.scrollTop = this.log.scrollHeight
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
typing.remove()
|
|
191
|
+
this.setBusy(false)
|
|
192
|
+
this.input.focus()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Register the element under `tag` (once); the script-tag build calls it for you. */
|
|
198
|
+
export function defineFronteraChat(tag = 'frontera-chat'): void {
|
|
199
|
+
if (typeof customElements === 'undefined' || customElements.get(tag)) return
|
|
200
|
+
customElements.define(tag, class extends FronteraChatElement {})
|
|
201
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script-tag build (`dist/frontera-chat.iife.js`): loading it registers
|
|
3
|
+
* `<frontera-chat>` and exposes `window.FronteraChat` for a chat of your own
|
|
4
|
+
* design. It carries everything it needs; there is nothing else to install.
|
|
5
|
+
*/
|
|
6
|
+
import { CHAT_ERROR_CODES, chatErrorCode, visitorErrorWords } from './chat-errors'
|
|
7
|
+
import { defineFronteraChat, FronteraChatElement } from './element'
|
|
8
|
+
import { PublicChat } from './public-chat'
|
|
9
|
+
import { mintPublicChatToken } from './public-token'
|
|
10
|
+
|
|
11
|
+
defineFronteraChat()
|
|
12
|
+
|
|
13
|
+
export { CHAT_ERROR_CODES, chatErrorCode, defineFronteraChat, FronteraChatElement, mintPublicChatToken, PublicChat, visitorErrorWords }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
|
+
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
3
|
+
|
|
4
|
+
import { chatErrorCode } from './chat-errors'
|
|
5
|
+
import { mintPublicChatToken, type PublicChatTokenOptions } from './public-token'
|
|
6
|
+
import { startChat } from './stream'
|
|
7
|
+
import type { ChatInputMessage } from './types'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A website visitor's conversation without React or the AI SDK (Forge
|
|
11
|
+
* improvement plan L4): mint the visitor's token, send a message, read the
|
|
12
|
+
* reply as it arrives. `<frontera-chat>` is built on it; use it directly for a
|
|
13
|
+
* chat of your own design.
|
|
14
|
+
*
|
|
15
|
+
* Under the Agent's reply checks the platform holds a reply until it is
|
|
16
|
+
* checked, so it can arrive in one piece after a pause: `waiting` says the
|
|
17
|
+
* Agent is working, and the text follows once it may be shown.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type PublicChatEvent =
|
|
21
|
+
| { type: 'waiting' }
|
|
22
|
+
| { type: 'text'; text: string }
|
|
23
|
+
| { type: 'done' }
|
|
24
|
+
| { type: 'error'; error: FronteraError }
|
|
25
|
+
|
|
26
|
+
export interface PublicChatOptions extends Omit<PublicChatTokenOptions, 'fetchImpl'> {
|
|
27
|
+
/** Test seam. Defaults to the global. */
|
|
28
|
+
fetchImpl?: typeof fetch
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function newId(): string {
|
|
32
|
+
return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `m-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The JSON events of a chat's server-sent event stream, the keepalive comments and the terminator left out. */
|
|
36
|
+
export async function* chatEvents(response: Response): AsyncGenerator<Record<string, unknown>> {
|
|
37
|
+
if (!response.body) return
|
|
38
|
+
const reader = response.body.getReader()
|
|
39
|
+
const decoder = new TextDecoder()
|
|
40
|
+
let buffer = ''
|
|
41
|
+
const parse = (block: string): Record<string, unknown> | null => {
|
|
42
|
+
const data = block
|
|
43
|
+
.split('\n')
|
|
44
|
+
.filter((line) => line.startsWith('data:'))
|
|
45
|
+
.map((line) => line.slice(5).trimStart())
|
|
46
|
+
.join('\n')
|
|
47
|
+
if (!data || data === '[DONE]') return null
|
|
48
|
+
try {
|
|
49
|
+
const value = JSON.parse(data) as unknown
|
|
50
|
+
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (;;) {
|
|
56
|
+
const { done, value } = await reader.read()
|
|
57
|
+
if (value) buffer += decoder.decode(value, { stream: true })
|
|
58
|
+
let end = buffer.indexOf('\n\n')
|
|
59
|
+
while (end !== -1) {
|
|
60
|
+
const event = parse(buffer.slice(0, end))
|
|
61
|
+
buffer = buffer.slice(end + 2)
|
|
62
|
+
if (event) yield event
|
|
63
|
+
end = buffer.indexOf('\n\n')
|
|
64
|
+
}
|
|
65
|
+
if (done) break
|
|
66
|
+
}
|
|
67
|
+
const last = parse(buffer)
|
|
68
|
+
if (last) yield last
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class PublicChat {
|
|
72
|
+
private token: string | null = null
|
|
73
|
+
private conversationId: string | null = null
|
|
74
|
+
/** Shown before the first message; sent back with it, so the conversation keeps it. */
|
|
75
|
+
openingMessage: string | null = null
|
|
76
|
+
private opened = false
|
|
77
|
+
|
|
78
|
+
constructor(private readonly options: PublicChatOptions) {}
|
|
79
|
+
|
|
80
|
+
/** Mint the visitor's token, and learn the Agent's opening message. Called by `send` when needed. */
|
|
81
|
+
async open(): Promise<{ openingMessage: string | null }> {
|
|
82
|
+
const minted = await mintPublicChatToken({ ...this.options, ...(this.options.fetchImpl ? { fetchImpl: this.options.fetchImpl } : {}) })
|
|
83
|
+
this.token = minted.token
|
|
84
|
+
if (!this.opened) this.openingMessage = minted.openingMessage ?? null
|
|
85
|
+
this.opened = true
|
|
86
|
+
return { openingMessage: this.openingMessage }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private client(): FronteraClient {
|
|
90
|
+
return new FronteraClient(
|
|
91
|
+
{ apiBaseUrl: this.options.apiBaseUrl, credential: { kind: 'token', token: this.token! } },
|
|
92
|
+
this.options.fetchImpl ?? fetch,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private messagesFor(text: string): ChatInputMessage[] {
|
|
97
|
+
const user = { id: newId(), role: 'user' as const, parts: [{ type: 'text' as const, text }] }
|
|
98
|
+
// The first turn carries the opening message the visitor saw, unchanged.
|
|
99
|
+
if (this.conversationId || !this.openingMessage) return [user]
|
|
100
|
+
return [{ id: newId(), role: 'assistant', parts: [{ type: 'text', text: this.openingMessage }] }, user]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Send one message and read the reply: `waiting`, then the reply's text as it grows, then `done` (or `error`). */
|
|
104
|
+
async *send(text: string, signal?: AbortSignal): AsyncGenerator<PublicChatEvent> {
|
|
105
|
+
yield { type: 'waiting' }
|
|
106
|
+
let response: Response
|
|
107
|
+
try {
|
|
108
|
+
if (!this.token) await this.open()
|
|
109
|
+
const start = () =>
|
|
110
|
+
startChat(this.client(), { messages: this.messagesFor(text), ...(this.conversationId ? { conversationId: this.conversationId } : {}) }, { signal })
|
|
111
|
+
let run
|
|
112
|
+
try {
|
|
113
|
+
run = await start()
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// A lapsed token: mint again (the visitor pass keeps the same visitor) and send once more.
|
|
116
|
+
if (chatErrorCode(err) !== 'UNAUTHORIZED') throw err
|
|
117
|
+
await this.open()
|
|
118
|
+
run = await start()
|
|
119
|
+
}
|
|
120
|
+
this.conversationId = run.conversationId
|
|
121
|
+
response = run.response
|
|
122
|
+
} catch (err) {
|
|
123
|
+
yield { type: 'error', error: asFronteraError(err) }
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const parts = new Map<string, string>()
|
|
128
|
+
const order: string[] = []
|
|
129
|
+
const whole = () => order.map((id) => parts.get(id) ?? '').filter(Boolean).join('\n\n')
|
|
130
|
+
try {
|
|
131
|
+
for await (const event of chatEvents(response)) {
|
|
132
|
+
if (event.type === 'text-delta' && typeof event.delta === 'string') {
|
|
133
|
+
const id = typeof event.id === 'string' ? event.id : 'text'
|
|
134
|
+
if (!parts.has(id)) order.push(id)
|
|
135
|
+
parts.set(id, (parts.get(id) ?? '') + event.delta)
|
|
136
|
+
yield { type: 'text', text: whole() }
|
|
137
|
+
} else if (event.type === 'error') {
|
|
138
|
+
const message = typeof event.errorText === 'string' ? event.errorText : 'The reply failed.'
|
|
139
|
+
yield { type: 'error', error: new FronteraError(message, { code: 'INTERNAL_ERROR' }) }
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
yield { type: 'error', error: asFronteraError(err) }
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
yield { type: 'done' }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function asFronteraError(err: unknown): FronteraError {
|
|
152
|
+
if (err instanceof FronteraError) return err
|
|
153
|
+
return new FronteraError(err instanceof Error ? err.message : String(err), { code: 'INTERNAL_ERROR' })
|
|
154
|
+
}
|
package/src/public-token.ts
CHANGED
|
@@ -26,6 +26,29 @@ export interface PublicChatTokenOptions {
|
|
|
26
26
|
fetchImpl?: typeof fetch
|
|
27
27
|
/** Test seam. Defaults to `localStorage`, when there is one. */
|
|
28
28
|
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null
|
|
29
|
+
/**
|
|
30
|
+
* The visitor's language (`en-GB`, `id`), for the Agent's opening message.
|
|
31
|
+
* Defaults to the browser's (`navigator.language`).
|
|
32
|
+
*/
|
|
33
|
+
language?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A visitor's token, with what the Agent says first when it has an opening message. */
|
|
37
|
+
export interface PublicChatToken extends ChatToken {
|
|
38
|
+
/**
|
|
39
|
+
* The Agent's opening message in the visitor's language, to show before they
|
|
40
|
+
* type. Send it back unchanged as an `assistant` message before the
|
|
41
|
+
* visitor's first message, and the conversation keeps it as the Agent's.
|
|
42
|
+
*/
|
|
43
|
+
openingMessage?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function browserLanguage(): string | undefined {
|
|
47
|
+
try {
|
|
48
|
+
return typeof navigator === 'undefined' ? undefined : navigator.language || undefined
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
29
52
|
}
|
|
30
53
|
|
|
31
54
|
/** One pass per key, so two Agents on one site never share a visitor. */
|
|
@@ -57,23 +80,32 @@ function write(storage: PublicChatTokenOptions['storage'], key: string, value: s
|
|
|
57
80
|
}
|
|
58
81
|
}
|
|
59
82
|
|
|
60
|
-
export async function mintPublicChatToken(options: PublicChatTokenOptions): Promise<
|
|
83
|
+
export async function mintPublicChatToken(options: PublicChatTokenOptions): Promise<PublicChatToken> {
|
|
61
84
|
const storage = options.storage === undefined ? defaultStorage() : options.storage
|
|
62
85
|
const passKey = visitorPassStorageKey(options.publishableKey)
|
|
63
86
|
const visitorPass = read(storage, passKey)
|
|
87
|
+
const language = options.language ?? browserLanguage()
|
|
64
88
|
|
|
65
89
|
const response = await (options.fetchImpl ?? fetch)(`${options.apiBaseUrl}/v1/chat-tokens/public`, {
|
|
66
90
|
method: 'POST',
|
|
67
91
|
headers: { 'content-type': 'application/json' },
|
|
68
92
|
// Never `include`: this call carries the key, not anyone's cookies.
|
|
69
93
|
credentials: 'omit',
|
|
70
|
-
body: JSON.stringify({
|
|
94
|
+
body: JSON.stringify({
|
|
95
|
+
publishableKey: options.publishableKey,
|
|
96
|
+
...(visitorPass ? { visitorPass } : {}),
|
|
97
|
+
...(language ? { language } : {}),
|
|
98
|
+
}),
|
|
71
99
|
})
|
|
72
100
|
const body = (await response.json().catch(() => null)) as
|
|
73
|
-
| { data?: { token: string; expiresAt: string; visitorPass?: string } }
|
|
101
|
+
| { data?: { token: string; expiresAt: string; visitorPass?: string; openingMessage?: string } }
|
|
74
102
|
| null
|
|
75
103
|
if (!response.ok || !body?.data) throw errorFromResponse(response.status, body)
|
|
76
104
|
|
|
77
105
|
if (body.data.visitorPass) write(storage, passKey, body.data.visitorPass)
|
|
78
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
token: body.data.token,
|
|
108
|
+
expiresAt: body.data.expiresAt,
|
|
109
|
+
...(body.data.openingMessage ? { openingMessage: body.data.openingMessage } : {}),
|
|
110
|
+
}
|
|
79
111
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An Agent's reply as a page without a Markdown renderer shows it (Forge
|
|
3
|
+
* improvement plan L4): citation links out unless the page shows sources,
|
|
4
|
+
* then paragraphs of plain text, bold and links. No HTML is ever produced
|
|
5
|
+
* from the reply; the element builds DOM nodes from these pieces.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** `[1](/cite/knowledge/…)`, a citation the platform links to its own source viewer. */
|
|
9
|
+
const CITATION = /\s?\[([^\]]*)\]\(\/cite\/[^)\s]*\)/g
|
|
10
|
+
|
|
11
|
+
/** The reply without its citations: a numbered one goes, a named one keeps its name. */
|
|
12
|
+
export function withoutCitations(text: string): string {
|
|
13
|
+
return text.replace(CITATION, (match, label: string) => (/^\s*\d+\s*$/.test(label) ? '' : match.startsWith(' ') ? ` ${label}` : label))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ReplyPiece = { kind: 'text'; text: string } | { kind: 'bold'; text: string } | { kind: 'link'; text: string; href: string }
|
|
17
|
+
|
|
18
|
+
/** Only web and mail links become links; anything else stays text. */
|
|
19
|
+
function safeHref(href: string): string | null {
|
|
20
|
+
return /^(https?:\/\/|mailto:)/i.test(href.trim()) ? href.trim() : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const INLINE = /\*\*([^*]+)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g
|
|
24
|
+
|
|
25
|
+
function pieces(line: string): ReplyPiece[] {
|
|
26
|
+
const out: ReplyPiece[] = []
|
|
27
|
+
let at = 0
|
|
28
|
+
for (const m of line.matchAll(INLINE)) {
|
|
29
|
+
if (m.index! > at) out.push({ kind: 'text', text: line.slice(at, m.index) })
|
|
30
|
+
if (m[1] !== undefined) out.push({ kind: 'bold', text: m[1] })
|
|
31
|
+
else {
|
|
32
|
+
const href = safeHref(m[3]!)
|
|
33
|
+
out.push(href ? { kind: 'link', text: m[2]!, href } : { kind: 'text', text: m[2]! })
|
|
34
|
+
}
|
|
35
|
+
at = m.index! + m[0].length
|
|
36
|
+
}
|
|
37
|
+
if (at < line.length) out.push({ kind: 'text', text: line.slice(at) })
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Paragraphs (split on blank lines), each a list of lines, each line its pieces. */
|
|
42
|
+
export function replyParagraphs(text: string, options: { showSources?: boolean } = {}): ReplyPiece[][][] {
|
|
43
|
+
const shown = options.showSources ? text : withoutCitations(text)
|
|
44
|
+
return shown
|
|
45
|
+
.split(/\n\s*\n/)
|
|
46
|
+
.map((p) => p.trim())
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((p) => p.split('\n').map((line) => pieces(line.replace(/^\s*[-*]\s+/, '• '))))
|
|
49
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { chatDoctor } from './doctor'
|
|
|
6
6
|
|
|
7
7
|
import type { StartChatRequest } from './types'
|
|
8
8
|
|
|
9
|
+
/** The id of the Agent's opening message a chat shows before the person's first message (L4). */
|
|
10
|
+
export const OPENING_MESSAGE_ID = 'frontera-opening'
|
|
11
|
+
|
|
9
12
|
export interface ChatTransportOptions {
|
|
10
13
|
/** Continue an existing conversation. Omit to let the server create one. */
|
|
11
14
|
conversationId?: string
|
|
@@ -176,11 +179,14 @@ export function createChatTransport(
|
|
|
176
179
|
*/
|
|
177
180
|
prepareSendMessagesRequest: ({ messages, body }) => {
|
|
178
181
|
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
|
|
182
|
+
// A new conversation's first turn carries the opening message the person saw, unchanged, so it is kept as the Agent's first message.
|
|
183
|
+
const before = lastUserMessage ? messages[messages.indexOf(lastUserMessage) - 1] : undefined
|
|
184
|
+
const opening = !conversationId && before?.role === 'assistant' && before.id === OPENING_MESSAGE_ID ? [before] : []
|
|
179
185
|
const trimmed =
|
|
180
186
|
options.lastMessageOnly === false
|
|
181
187
|
? messages
|
|
182
188
|
: lastUserMessage
|
|
183
|
-
? [lastUserMessage]
|
|
189
|
+
? [...opening, lastUserMessage]
|
|
184
190
|
: messages.slice(-1)
|
|
185
191
|
return {
|
|
186
192
|
api: `${current().config.apiBaseUrl}/v1/chat`,
|