@frontera-sdk/chat 1.50.40
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/LICENSE +202 -0
- package/README.md +215 -0
- package/package.json +110 -0
- package/src/auth.ts +27 -0
- package/src/callback-page.ts +133 -0
- package/src/chat-client.ts +472 -0
- package/src/connect-popup.ts +352 -0
- package/src/doctor.ts +176 -0
- package/src/hooks.ts +202 -0
- package/src/provider.tsx +25 -0
- package/src/stream.ts +61 -0
- package/src/token-session.ts +182 -0
- package/src/transport.ts +200 -0
- package/src/types.ts +370 -0
- package/src/ui-stream.ts +49 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser half of connecting an end user's own account.
|
|
3
|
+
*
|
|
4
|
+
* The server side of this flow is three calls (`startAppConnection`,
|
|
5
|
+
* `completeAppConnection`, `resumeToolCall`) with a consent screen in the
|
|
6
|
+
* middle. What is fiddly is not the calls — it is the middle: opening a popup,
|
|
7
|
+
* knowing when it came back, telling "the user finished" apart from "the user
|
|
8
|
+
* closed the window", and not leaving a listener attached forever when neither
|
|
9
|
+
* happens.
|
|
10
|
+
*
|
|
11
|
+
* That is what lives here. It is deliberately not part of `ChatClient`: the
|
|
12
|
+
* client is transport, and this touches `window`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ChatClient } from './chat-client'
|
|
16
|
+
import type { ResumeToolCallResult } from './types'
|
|
17
|
+
|
|
18
|
+
/** What a callback page sends back to the window that opened it. */
|
|
19
|
+
export const CONNECT_MESSAGE_TYPE = 'frontera:oauth-callback'
|
|
20
|
+
|
|
21
|
+
export interface ConnectCallbackMessage {
|
|
22
|
+
type: typeof CONNECT_MESSAGE_TYPE
|
|
23
|
+
code?: string
|
|
24
|
+
state?: string
|
|
25
|
+
/** The provider's own refusal — the user pressed Cancel, typically. */
|
|
26
|
+
error?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where a redirect-mode connect parked what it needs to finish. */
|
|
30
|
+
const PENDING_KEY = 'frontera:connect-pending'
|
|
31
|
+
/** Query parameters the callback adds when returning a whole tab. */
|
|
32
|
+
const RETURN_CODE = 'frontera_code'
|
|
33
|
+
const RETURN_STATE = 'frontera_state'
|
|
34
|
+
const RETURN_ERROR = 'frontera_error'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Call this FROM YOUR CALLBACK PAGE — the one you registered as the OAuth
|
|
38
|
+
* redirect URI. It is the page's entire implementation:
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* // app/frontera/oauth/callback/page.tsx
|
|
42
|
+
* import { postConnectResultToOpener } from '@frontera-sdk/chat/connect-popup'
|
|
43
|
+
* postConnectResultToOpener(window.location.search)
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* It handles both ways a connect can arrive back. In a popup it posts to the
|
|
47
|
+
* opener and closes. In a redirected tab — which is what mobile gets, because
|
|
48
|
+
* popups are blocked or mangled in iOS Safari and in-app browsers — there is no
|
|
49
|
+
* opener, so it navigates back to where the chat was with the result attached.
|
|
50
|
+
*
|
|
51
|
+
* `targetOrigin` defaults to this page's own origin, correct when the chat and
|
|
52
|
+
* the callback are the same app. Never `'*'`: the message carries an
|
|
53
|
+
* authorization code.
|
|
54
|
+
*/
|
|
55
|
+
export function postConnectResultToOpener(search: string, targetOrigin?: string): void {
|
|
56
|
+
const params = new URLSearchParams(search)
|
|
57
|
+
const code = params.get('code') ?? undefined
|
|
58
|
+
const state = params.get('state') ?? undefined
|
|
59
|
+
const error = params.get('error') ?? undefined
|
|
60
|
+
|
|
61
|
+
if (window.opener) {
|
|
62
|
+
const message: ConnectCallbackMessage = { type: CONNECT_MESSAGE_TYPE, code, state, error }
|
|
63
|
+
window.opener.postMessage(message, targetOrigin ?? window.location.origin)
|
|
64
|
+
window.close()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Redirect mode: the chat is not open behind us — it was this tab. The
|
|
69
|
+
// return address was stored before we left, because the redirect URI is
|
|
70
|
+
// fixed at the provider and cannot carry it.
|
|
71
|
+
const pending = readPending()
|
|
72
|
+
const target = new URL(pending?.returnTo ?? window.location.origin)
|
|
73
|
+
if (code) target.searchParams.set(RETURN_CODE, code)
|
|
74
|
+
if (state) target.searchParams.set(RETURN_STATE, state)
|
|
75
|
+
if (error) target.searchParams.set(RETURN_ERROR, error)
|
|
76
|
+
window.location.replace(target.toString())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PendingConnect {
|
|
80
|
+
/** Null for a voluntary connect — see `RunAppConnectionOptions`. */
|
|
81
|
+
pendingToolCallId: string | null
|
|
82
|
+
returnTo: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readPending(): PendingConnect | null {
|
|
86
|
+
try {
|
|
87
|
+
const raw = window.sessionStorage.getItem(PENDING_KEY)
|
|
88
|
+
return raw ? (JSON.parse(raw) as PendingConnect) : null
|
|
89
|
+
} catch {
|
|
90
|
+
// Private mode, a blocked storage partition, a corrupt value — none of
|
|
91
|
+
// which should throw out of a callback page whose only job is to forward.
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writePending(value: PendingConnect | null): void {
|
|
97
|
+
try {
|
|
98
|
+
if (value) window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(value))
|
|
99
|
+
else window.sessionStorage.removeItem(PENDING_KEY)
|
|
100
|
+
} catch {
|
|
101
|
+
/* see readPending */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RunAppConnectionOptions {
|
|
106
|
+
/**
|
|
107
|
+
* The parked call's id — what gets resumed once the account exists.
|
|
108
|
+
*
|
|
109
|
+
* Omit for a VOLUNTARY connect: someone connecting an account from the
|
|
110
|
+
* composer before asking anything has no parked call, and there is nothing
|
|
111
|
+
* to resume. The account still lands the same way; only the last step
|
|
112
|
+
* differs.
|
|
113
|
+
*/
|
|
114
|
+
pendingToolCallId?: string
|
|
115
|
+
/** Which app. Take `installId` from the parked call. */
|
|
116
|
+
installId?: string
|
|
117
|
+
catalogKind?: string
|
|
118
|
+
/** Your callback page, registered with your OAuth client AND allowlisted. */
|
|
119
|
+
redirectUri: string
|
|
120
|
+
/**
|
|
121
|
+
* Origin the callback message must come from. Defaults to `redirectUri`'s
|
|
122
|
+
* origin, which is where your callback page runs.
|
|
123
|
+
*/
|
|
124
|
+
expectedOrigin?: string
|
|
125
|
+
/** How long to wait for the user before giving up. Default 5 minutes. */
|
|
126
|
+
timeoutMs?: number
|
|
127
|
+
/** Popup geometry, if the defaults do not suit your layout. */
|
|
128
|
+
features?: string
|
|
129
|
+
/**
|
|
130
|
+
* How to send the person to the provider.
|
|
131
|
+
*
|
|
132
|
+
* `auto` (default) tries a popup and falls back to redirecting this tab when
|
|
133
|
+
* the browser refuses one — which is the normal case on mobile: iOS Safari
|
|
134
|
+
* and in-app browsers (Instagram, LinkedIn, Slack) block or mangle
|
|
135
|
+
* `window.open`. A popup-only integration has a dead Connect button for
|
|
136
|
+
* every one of those users.
|
|
137
|
+
*
|
|
138
|
+
* `redirect` always navigates. `popup` never does, and reports the block.
|
|
139
|
+
*/
|
|
140
|
+
mode?: 'auto' | 'popup' | 'redirect'
|
|
141
|
+
/**
|
|
142
|
+
* Where to come back to after a redirect. Defaults to the current URL, so
|
|
143
|
+
* the person lands where they left. Its ORIGIN must be on the workspace's
|
|
144
|
+
* embed allowlist.
|
|
145
|
+
*/
|
|
146
|
+
returnTo?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type RunAppConnectionResult =
|
|
150
|
+
/**
|
|
151
|
+
* The popup path completed here.
|
|
152
|
+
*
|
|
153
|
+
* `resume` is absent when nothing was parked — the plugins button connects
|
|
154
|
+
* an account with no tool call waiting on it, and the connect was the whole
|
|
155
|
+
* errand. Present only when a parked call was released by this connection.
|
|
156
|
+
*/
|
|
157
|
+
| { status: 'connected'; resume?: ResumeToolCallResult }
|
|
158
|
+
| { status: 'cancelled'; reason: 'closed' | 'denied' | 'timeout'; error?: string }
|
|
159
|
+
/**
|
|
160
|
+
* The tab is navigating to the provider. Nothing after this call runs — the
|
|
161
|
+
* flow continues in `completeRedirectedConnection` when the person comes
|
|
162
|
+
* back.
|
|
163
|
+
*/
|
|
164
|
+
| { status: 'redirecting' }
|
|
165
|
+
|
|
166
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60_000
|
|
167
|
+
const DEFAULT_FEATURES = 'width=520,height=680,menubar=no,toolbar=no'
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run the whole thing: open the consent popup, wait for your callback to post
|
|
171
|
+
* the code back, exchange it, and re-run the parked tool call.
|
|
172
|
+
*
|
|
173
|
+
* Resolves rather than throwing when the person simply declines — a closed
|
|
174
|
+
* popup is an ordinary outcome and reads badly as an exception. Genuine
|
|
175
|
+
* failures (a refused start, a rejected exchange) still throw `FronteraError`
|
|
176
|
+
* with the server's code.
|
|
177
|
+
*
|
|
178
|
+
* The resume is included on purpose. Connecting without it leaves the agent
|
|
179
|
+
* exactly as blocked as before: it is waiting on a tool RESULT, not on an
|
|
180
|
+
* account.
|
|
181
|
+
*/
|
|
182
|
+
export async function runAppConnection(
|
|
183
|
+
chat: ChatClient,
|
|
184
|
+
options: RunAppConnectionOptions,
|
|
185
|
+
): Promise<RunAppConnectionResult> {
|
|
186
|
+
const mode = options.mode ?? 'auto'
|
|
187
|
+
const returnTo = options.returnTo ?? window.location.href
|
|
188
|
+
|
|
189
|
+
const { authUrl } = await chat.startAppConnection({
|
|
190
|
+
installId: options.installId,
|
|
191
|
+
catalogKind: options.catalogKind,
|
|
192
|
+
redirectUri: options.redirectUri,
|
|
193
|
+
returnTo,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
const expectedOrigin = options.expectedOrigin ?? new URL(options.redirectUri).origin
|
|
197
|
+
|
|
198
|
+
const goByRedirect = (): RunAppConnectionResult => {
|
|
199
|
+
// Parked before navigating, because this tab is about to be replaced: the
|
|
200
|
+
// callback page reads it to find the way back, and the chat reads it on
|
|
201
|
+
// return to know which call to resume. The provider's redirect URI is
|
|
202
|
+
// fixed and cannot carry either.
|
|
203
|
+
writePending({ pendingToolCallId: options.pendingToolCallId ?? null, returnTo })
|
|
204
|
+
window.location.assign(authUrl)
|
|
205
|
+
return { status: 'redirecting' }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (mode === 'redirect') return goByRedirect()
|
|
209
|
+
|
|
210
|
+
// Popup blockers judge by user gesture, and the awaited start call above may
|
|
211
|
+
// already have cost us that association. That is not a failure to report on
|
|
212
|
+
// mobile — it is the norm — so `auto` simply takes the other road.
|
|
213
|
+
const popup = window.open(authUrl, 'frontera-connect', options.features ?? DEFAULT_FEATURES)
|
|
214
|
+
if (!popup) {
|
|
215
|
+
if (mode === 'auto') return goByRedirect()
|
|
216
|
+
throw new Error(
|
|
217
|
+
'The connection window was blocked. Open it from a direct click, or allow popups for this site.',
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const outcome = await waitForCallback(popup, expectedOrigin, options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
222
|
+
if (outcome.status !== 'received') return { status: 'cancelled', reason: outcome.reason, error: outcome.error }
|
|
223
|
+
|
|
224
|
+
await chat.completeAppConnection({ code: outcome.code, state: outcome.state })
|
|
225
|
+
// Nothing parked, nothing to resume — the connect WAS the whole errand.
|
|
226
|
+
if (!options.pendingToolCallId) return { status: 'connected' }
|
|
227
|
+
const resume = await chat.resumeToolCall(options.pendingToolCallId)
|
|
228
|
+
return { status: 'connected', resume }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Finish a connect that came back as a page load rather than a popup message.
|
|
233
|
+
*
|
|
234
|
+
* Call it once when your chat mounts. It does nothing — cheaply, synchronously
|
|
235
|
+
* — unless this page load is a return from a provider, so it is safe to call
|
|
236
|
+
* unconditionally:
|
|
237
|
+
*
|
|
238
|
+
* ```ts
|
|
239
|
+
* useEffect(() => { void completeRedirectedConnection(chat) }, [chat])
|
|
240
|
+
* ```
|
|
241
|
+
*
|
|
242
|
+
* Without it the redirect path stops one step short: the account gets
|
|
243
|
+
* connected and the parked tool call is never re-run, which looks exactly like
|
|
244
|
+
* the freeze the whole flow exists to prevent. It also tidies the query string,
|
|
245
|
+
* so a refresh does not try to redeem a code that is already spent.
|
|
246
|
+
*/
|
|
247
|
+
export async function completeRedirectedConnection(
|
|
248
|
+
chat: ChatClient,
|
|
249
|
+
options: { search?: string } = {},
|
|
250
|
+
): Promise<RunAppConnectionResult | null> {
|
|
251
|
+
if (typeof window === 'undefined') return null
|
|
252
|
+
|
|
253
|
+
const params = new URLSearchParams(options.search ?? window.location.search)
|
|
254
|
+
const code = params.get(RETURN_CODE)
|
|
255
|
+
const state = params.get(RETURN_STATE)
|
|
256
|
+
const error = params.get(RETURN_ERROR)
|
|
257
|
+
if (!code && !state && !error) return null
|
|
258
|
+
|
|
259
|
+
const pending = readPending()
|
|
260
|
+
writePending(null)
|
|
261
|
+
clearReturnParams(params)
|
|
262
|
+
|
|
263
|
+
if (error || !code || !state) {
|
|
264
|
+
return { status: 'cancelled', reason: 'denied', error: error ?? 'callback carried no code' }
|
|
265
|
+
}
|
|
266
|
+
if (!pending) {
|
|
267
|
+
// The code is real but we no longer know which call it was for — a tab
|
|
268
|
+
// restored from history, or storage cleared mid-flow. The connection can
|
|
269
|
+
// still be completed; only the resume is lost, and the next attempt at the
|
|
270
|
+
// tool will find an account waiting.
|
|
271
|
+
await chat.completeAppConnection({ code, state })
|
|
272
|
+
return { status: 'connected', resume: { ok: true } }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await chat.completeAppConnection({ code, state })
|
|
276
|
+
// A voluntary connect parks nothing, so returning from one has nothing to
|
|
277
|
+
// resume — the account is the outcome.
|
|
278
|
+
if (!pending.pendingToolCallId) return { status: 'connected' }
|
|
279
|
+
const resume = await chat.resumeToolCall(pending.pendingToolCallId)
|
|
280
|
+
return { status: 'connected', resume }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Strip our parameters from the address bar without reloading or losing theirs. */
|
|
284
|
+
function clearReturnParams(params: URLSearchParams): void {
|
|
285
|
+
params.delete(RETURN_CODE)
|
|
286
|
+
params.delete(RETURN_STATE)
|
|
287
|
+
params.delete(RETURN_ERROR)
|
|
288
|
+
const query = params.toString()
|
|
289
|
+
window.history.replaceState(
|
|
290
|
+
window.history.state,
|
|
291
|
+
'',
|
|
292
|
+
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type CallbackOutcome =
|
|
297
|
+
| { status: 'received'; code: string; state: string }
|
|
298
|
+
| { status: 'cancelled'; reason: 'closed' | 'denied' | 'timeout'; error?: string }
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Wait for whichever comes first: the callback message, the popup closing, or
|
|
302
|
+
* the timeout.
|
|
303
|
+
*
|
|
304
|
+
* The poll for `popup.closed` is what makes "the user gave up" a real outcome
|
|
305
|
+
* instead of a promise that never settles — there is no close event across
|
|
306
|
+
* windows. Every path runs the same teardown, because a listener left on
|
|
307
|
+
* `window` after a cancelled connect will happily act on the NEXT one.
|
|
308
|
+
*/
|
|
309
|
+
function waitForCallback(
|
|
310
|
+
popup: Window,
|
|
311
|
+
expectedOrigin: string,
|
|
312
|
+
timeoutMs: number,
|
|
313
|
+
): Promise<CallbackOutcome> {
|
|
314
|
+
return new Promise<CallbackOutcome>((resolve) => {
|
|
315
|
+
let settled = false
|
|
316
|
+
|
|
317
|
+
const finish = (outcome: CallbackOutcome) => {
|
|
318
|
+
if (settled) return
|
|
319
|
+
settled = true
|
|
320
|
+
window.removeEventListener('message', onMessage)
|
|
321
|
+
clearInterval(closeTimer)
|
|
322
|
+
clearTimeout(timeoutTimer)
|
|
323
|
+
resolve(outcome)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const onMessage = (event: MessageEvent) => {
|
|
327
|
+
// The origin check is the security boundary: this message carries an
|
|
328
|
+
// authorization code, and any page may post to an opener.
|
|
329
|
+
if (event.origin !== expectedOrigin) return
|
|
330
|
+
const data = event.data as ConnectCallbackMessage | undefined
|
|
331
|
+
if (!data || data.type !== CONNECT_MESSAGE_TYPE) return
|
|
332
|
+
|
|
333
|
+
if (data.error) return finish({ status: 'cancelled', reason: 'denied', error: data.error })
|
|
334
|
+
if (!data.code || !data.state) {
|
|
335
|
+
return finish({ status: 'cancelled', reason: 'denied', error: 'callback carried no code' })
|
|
336
|
+
}
|
|
337
|
+
finish({ status: 'received', code: data.code, state: data.state })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
window.addEventListener('message', onMessage)
|
|
341
|
+
|
|
342
|
+
const closeTimer = setInterval(() => {
|
|
343
|
+
// A small grace period is unnecessary: the callback page posts BEFORE it
|
|
344
|
+
// closes, and `message` is delivered in order.
|
|
345
|
+
if (popup.closed) finish({ status: 'cancelled', reason: 'closed' })
|
|
346
|
+
}, 400)
|
|
347
|
+
|
|
348
|
+
const timeoutTimer = setTimeout(() => {
|
|
349
|
+
finish({ status: 'cancelled', reason: 'timeout' })
|
|
350
|
+
}, timeoutMs)
|
|
351
|
+
})
|
|
352
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A development-time watchdog for the ways a chat goes quiet.
|
|
3
|
+
*
|
|
4
|
+
* The failure this exists for looks like nothing at all: the agent stops
|
|
5
|
+
* mid-turn because it is waiting for a person, the integration does not render
|
|
6
|
+
* the card that would let them answer, and the run sits there until it lapses.
|
|
7
|
+
* No error, no rejected promise, no failed request — which is precisely why it
|
|
8
|
+
* reaches production. The first anyone hears of it is a user saying "it froze".
|
|
9
|
+
*
|
|
10
|
+
* So the SDK watches its own stream in development. When something parks and
|
|
11
|
+
* nothing settles it within a grace period, this says so, in the integrator's
|
|
12
|
+
* console, naming the call they are missing. Silence becomes a message at the
|
|
13
|
+
* moment it happens rather than a support ticket a week later.
|
|
14
|
+
*
|
|
15
|
+
* Off in production, always: it tees the response body to read it, and that is
|
|
16
|
+
* a cost worth paying for a warning nobody in production would see.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type DoctorConcern = 'approval' | 'connect' | 'unauthorized'
|
|
20
|
+
|
|
21
|
+
export interface ChatDoctorOptions {
|
|
22
|
+
/** Force on or off. Defaults to on outside production. */
|
|
23
|
+
enabled?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* How long a parked run may go unanswered before it is worth mentioning.
|
|
26
|
+
*
|
|
27
|
+
* Long enough that a UI fetching its pending list on the next tick is never
|
|
28
|
+
* scolded; short enough to land while the developer is still looking at the
|
|
29
|
+
* screen that caused it.
|
|
30
|
+
*/
|
|
31
|
+
graceMs?: number
|
|
32
|
+
/** Where warnings go. Defaults to `console.warn`. */
|
|
33
|
+
onWarn?: (concern: DoctorConcern, message: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_GRACE_MS = 10_000
|
|
37
|
+
|
|
38
|
+
function defaultEnabled(): boolean {
|
|
39
|
+
// `process` is absent in a browser bundle that did not shim it; absent means
|
|
40
|
+
// we cannot prove production, and a dev warning is the safer default.
|
|
41
|
+
const mode =
|
|
42
|
+
typeof process !== 'undefined' ? (process.env?.NODE_ENV ?? undefined) : undefined
|
|
43
|
+
return mode !== 'production'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ADVICE: Record<DoctorConcern, string> = {
|
|
47
|
+
approval: [
|
|
48
|
+
'A tool call is parked waiting for a person to approve it, and nothing has answered.',
|
|
49
|
+
'The run is alive and BLOCKED — it will not continue until it is settled or expires.',
|
|
50
|
+
'',
|
|
51
|
+
' const pending = await chat.listPendingApprovals(conversationId)',
|
|
52
|
+
" await chat.respondToApproval(pending[0].id, 'approve')",
|
|
53
|
+
'',
|
|
54
|
+
'Using @frontera-sdk/chat-ui, render <PendingCards conversationId={…} />.',
|
|
55
|
+
'Read the pending list on mount too: a reload loses the stream chunk, not the block.',
|
|
56
|
+
].join('\n'),
|
|
57
|
+
connect: [
|
|
58
|
+
"A tool needs this user's own account and returned `needs_connect`; nothing has started a connection.",
|
|
59
|
+
'The agent has been told NOT to ask in prose, so if your UI stays silent, nothing is said.',
|
|
60
|
+
'',
|
|
61
|
+
' await runAppConnection(chat, { pendingToolCallId, installId, redirectUri })',
|
|
62
|
+
'',
|
|
63
|
+
'Using @frontera-sdk/chat-ui, pass `connectRedirectUri` to <FronteraChat>.',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
unauthorized: [
|
|
66
|
+
'A request was refused with UNAUTHORIZED and no refresh is wired, so the next send will fail too.',
|
|
67
|
+
'',
|
|
68
|
+
' const session = createTokenSession({ apiBaseUrl, getToken })',
|
|
69
|
+
' createChatTransport(session.getClient, { onUnauthorized: session.refresh })',
|
|
70
|
+
].join('\n'),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class ChatDoctor {
|
|
74
|
+
private options: ChatDoctorOptions = {}
|
|
75
|
+
private timers = new Map<DoctorConcern, ReturnType<typeof setTimeout>>()
|
|
76
|
+
|
|
77
|
+
configure(options: ChatDoctorOptions): void {
|
|
78
|
+
this.options = { ...this.options, ...options }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get enabled(): boolean {
|
|
82
|
+
return this.options.enabled ?? defaultEnabled()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private warn(concern: DoctorConcern): void {
|
|
86
|
+
const message = `[frontera-chat] ${ADVICE[concern]}`
|
|
87
|
+
if (this.options.onWarn) this.options.onWarn(concern, message)
|
|
88
|
+
else console.warn(message)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Something parked. Start counting; `resolved` before the grace period ends
|
|
93
|
+
* cancels it.
|
|
94
|
+
*/
|
|
95
|
+
parked(concern: DoctorConcern): void {
|
|
96
|
+
if (!this.enabled || this.timers.has(concern)) return
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
this.timers.delete(concern)
|
|
99
|
+
this.warn(concern)
|
|
100
|
+
}, this.options.graceMs ?? DEFAULT_GRACE_MS)
|
|
101
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
102
|
+
this.timers.set(concern, timer)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The integration did the thing. Nothing to warn about. */
|
|
106
|
+
resolved(concern: DoctorConcern): void {
|
|
107
|
+
const timer = this.timers.get(concern)
|
|
108
|
+
if (!timer) return
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
this.timers.delete(concern)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Immediate, not timed: nothing about a missing refresh improves by waiting. */
|
|
114
|
+
noticed(concern: DoctorConcern): void {
|
|
115
|
+
if (this.enabled) this.warn(concern)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** For tests, and for a host that unmounts a chat mid-run. */
|
|
119
|
+
reset(): void {
|
|
120
|
+
for (const timer of this.timers.values()) clearTimeout(timer)
|
|
121
|
+
this.timers.clear()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Watch a chat stream for the two things that park a run.
|
|
126
|
+
*
|
|
127
|
+
* The body is teed rather than parsed: this reads a copy as text and matches
|
|
128
|
+
* two markers, leaving the real stream untouched for the AI SDK. A full parse
|
|
129
|
+
* here would be a second implementation of the protocol, kept in step with
|
|
130
|
+
* the first by hope.
|
|
131
|
+
*/
|
|
132
|
+
observe(response: Response): Response {
|
|
133
|
+
if (!this.enabled || !response.body) return response
|
|
134
|
+
|
|
135
|
+
const [forCaller, forDoctor] = response.body.tee()
|
|
136
|
+
void this.scan(forDoctor)
|
|
137
|
+
|
|
138
|
+
// Headers and status are carried over: callers read the conversation and
|
|
139
|
+
// run ids off them, and a Response rebuilt without them breaks everything
|
|
140
|
+
// downstream of a warning that was supposed to be passive.
|
|
141
|
+
return new Response(forCaller, {
|
|
142
|
+
status: response.status,
|
|
143
|
+
statusText: response.statusText,
|
|
144
|
+
headers: response.headers,
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async scan(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
149
|
+
const reader = stream.getReader()
|
|
150
|
+
const decoder = new TextDecoder()
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const { done, value } = await reader.read()
|
|
154
|
+
if (done) break
|
|
155
|
+
const text = decoder.decode(value, { stream: true })
|
|
156
|
+
if (text.includes('tool-approval-request')) this.parked('approval')
|
|
157
|
+
if (text.includes('needs_connect')) this.parked('connect')
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// A cancelled or broken stream is not a diagnostic finding. The caller's
|
|
161
|
+
// copy reports the failure through its own path.
|
|
162
|
+
} finally {
|
|
163
|
+
reader.releaseLock()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The instance the SDK reports into. Exported so a host can quieten it, widen
|
|
170
|
+
* the grace period, or route warnings into their own logger:
|
|
171
|
+
*
|
|
172
|
+
* ```ts
|
|
173
|
+
* chatDoctor.configure({ enabled: false })
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
export const chatDoctor = new ChatDoctor()
|