@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.
Files changed (51) hide show
  1. package/dist/auth.d.ts +20 -0
  2. package/dist/auth.js +11 -0
  3. package/dist/callback-page.d.ts +49 -0
  4. package/dist/callback-page.js +84 -0
  5. package/dist/chat-client.d.ts +221 -0
  6. package/dist/chat-client.js +5 -0
  7. package/dist/chat-errors.d.ts +30 -0
  8. package/dist/chat-errors.js +3 -0
  9. package/dist/chunk-CMMWTCYS.js +116 -0
  10. package/dist/chunk-KZEGEV3P.js +35 -0
  11. package/dist/chunk-LS26G2N6.js +120 -0
  12. package/dist/chunk-RLHHRJWS.js +371 -0
  13. package/dist/chunk-UCUVHMJC.js +62 -0
  14. package/dist/chunk-VWUR3ECR.js +115 -0
  15. package/dist/chunk-W25Q2DQA.js +32 -0
  16. package/dist/chunk-YKQ5Z5EK.js +32 -0
  17. package/dist/connect-popup.d.ts +150 -0
  18. package/dist/connect-popup.js +138 -0
  19. package/dist/doctor.d.ts +71 -0
  20. package/dist/doctor.js +3 -0
  21. package/dist/element.d.ts +23 -0
  22. package/dist/element.js +175 -0
  23. package/dist/frontera-chat.iife.js +27 -0
  24. package/dist/hooks.d.ts +88 -0
  25. package/dist/hooks.js +3 -0
  26. package/dist/provider.d.ts +20 -0
  27. package/dist/provider.js +13 -0
  28. package/dist/public-chat.d.ts +50 -0
  29. package/dist/public-chat.js +6 -0
  30. package/dist/public-token.d.ts +46 -0
  31. package/dist/public-token.js +3 -0
  32. package/dist/reply-text.d.ts +25 -0
  33. package/dist/reply-text.js +3 -0
  34. package/dist/stream.d.ts +25 -0
  35. package/dist/stream.js +3 -0
  36. package/dist/token-session.d.ts +66 -0
  37. package/dist/token-session.js +99 -0
  38. package/dist/transport.d.ts +60 -0
  39. package/dist/transport.js +103 -0
  40. package/dist/types.d.ts +361 -0
  41. package/dist/types.js +3 -0
  42. package/dist/ui-stream.d.ts +25 -0
  43. package/dist/ui-stream.js +26 -0
  44. package/package.json +70 -30
  45. package/src/chat-errors.ts +43 -0
  46. package/src/element.ts +201 -0
  47. package/src/frontera-chat.global.ts +13 -0
  48. package/src/public-chat.ts +154 -0
  49. package/src/public-token.ts +36 -4
  50. package/src/reply-text.ts +49 -0
  51. 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
+ }
@@ -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<ChatToken> {
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({ publishableKey: options.publishableKey, ...(visitorPass ? { visitorPass } : {}) }),
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 { token: body.data.token, expiresAt: body.data.expiresAt }
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`,