@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
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
useMutation,
|
|
4
|
+
useQuery,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
type UseMutationOptions,
|
|
7
|
+
type UseMutationResult,
|
|
8
|
+
type UseQueryOptions,
|
|
9
|
+
type UseQueryResult,
|
|
10
|
+
} from '@tanstack/react-query'
|
|
11
|
+
|
|
12
|
+
import type { ChatClient } from './chat-client'
|
|
13
|
+
import type {
|
|
14
|
+
ApprovalDecision,
|
|
15
|
+
CreateSessionRequest,
|
|
16
|
+
ListSessionsQuery,
|
|
17
|
+
PendingApproval,
|
|
18
|
+
Paginated,
|
|
19
|
+
Session,
|
|
20
|
+
SessionMessage,
|
|
21
|
+
UpdateSessionRequest,
|
|
22
|
+
} from './types'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Query-key factory, same convention as `blueprintKeys`: requests serialise
|
|
26
|
+
* into the key so structurally equal requests share a cache entry.
|
|
27
|
+
*/
|
|
28
|
+
export const chatKeys = {
|
|
29
|
+
all: ['chat'] as const,
|
|
30
|
+
sessions: (query?: ListSessionsQuery) =>
|
|
31
|
+
['chat', 'sessions', JSON.stringify(query ?? {})] as const,
|
|
32
|
+
session: (id: string) => ['chat', 'session', id] as const,
|
|
33
|
+
messages: (id: string) => ['chat', 'messages', id] as const,
|
|
34
|
+
followUps: (conversationId: string, messageId: string) =>
|
|
35
|
+
['chat', 'follow-ups', conversationId, messageId] as const,
|
|
36
|
+
approvals: (conversationId: string) => ['chat', 'approvals', conversationId] as const,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const FronteraChatContext = createContext<ChatClient | null>(null)
|
|
40
|
+
|
|
41
|
+
export function useChatClient(): ChatClient {
|
|
42
|
+
const client = useContext(FronteraChatContext)
|
|
43
|
+
if (!client) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'No ChatClient in context. Wrap the tree in FronteraChatContext.Provider — in a Frontera app, add chatProvider to createFronteraApp providers.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return client
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn' | 'select'>
|
|
52
|
+
type WriteOptions<TData, TVariables> = Omit<
|
|
53
|
+
UseMutationOptions<TData, Error, TVariables>,
|
|
54
|
+
'mutationFn'
|
|
55
|
+
>
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* These hooks cover PERSISTED state (sessions, history). Live streaming state
|
|
59
|
+
* belongs to `useChat` from `@ai-sdk/react` with `createChatTransport` — there
|
|
60
|
+
* is deliberately no second streaming state machine here.
|
|
61
|
+
*/
|
|
62
|
+
export function useSessions(
|
|
63
|
+
query?: ListSessionsQuery,
|
|
64
|
+
options: ReadOptions<Paginated<Session>> = {},
|
|
65
|
+
): UseQueryResult<Paginated<Session>, Error> {
|
|
66
|
+
const client = useChatClient()
|
|
67
|
+
return useQuery({
|
|
68
|
+
queryKey: chatKeys.sessions(query),
|
|
69
|
+
queryFn: () => client.listSessions(query),
|
|
70
|
+
...options,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useSession(
|
|
75
|
+
id: string,
|
|
76
|
+
options: ReadOptions<Session> = {},
|
|
77
|
+
): UseQueryResult<Session, Error> {
|
|
78
|
+
const client = useChatClient()
|
|
79
|
+
return useQuery({
|
|
80
|
+
queryKey: chatKeys.session(id),
|
|
81
|
+
queryFn: () => client.getSession(id),
|
|
82
|
+
...options,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function useSessionMessages(
|
|
87
|
+
id: string,
|
|
88
|
+
options: ReadOptions<SessionMessage[]> = {},
|
|
89
|
+
): UseQueryResult<SessionMessage[], Error> {
|
|
90
|
+
const client = useChatClient()
|
|
91
|
+
return useQuery({
|
|
92
|
+
queryKey: chatKeys.messages(id),
|
|
93
|
+
queryFn: () => client.getMessages(id),
|
|
94
|
+
...options,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useFollowUps(
|
|
99
|
+
conversationId: string,
|
|
100
|
+
messageId: string,
|
|
101
|
+
options: ReadOptions<{ suggestions: unknown[] }> = {},
|
|
102
|
+
): UseQueryResult<{ suggestions: unknown[] }, Error> {
|
|
103
|
+
const client = useChatClient()
|
|
104
|
+
return useQuery({
|
|
105
|
+
queryKey: chatKeys.followUps(conversationId, messageId),
|
|
106
|
+
queryFn: () => client.getFollowUps(conversationId, messageId),
|
|
107
|
+
...options,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function useCreateSession(
|
|
112
|
+
options: WriteOptions<Session, CreateSessionRequest> = {},
|
|
113
|
+
): UseMutationResult<Session, Error, CreateSessionRequest> {
|
|
114
|
+
const client = useChatClient()
|
|
115
|
+
const queryClient = useQueryClient()
|
|
116
|
+
return useMutation({
|
|
117
|
+
mutationFn: (request: CreateSessionRequest) => client.createSession(request),
|
|
118
|
+
...options,
|
|
119
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
120
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.all })
|
|
121
|
+
return options.onSuccess?.(data, variables, context, mutation)
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function useUpdateSession(
|
|
127
|
+
options: WriteOptions<{ success: boolean }, { id: string; patch: UpdateSessionRequest }> = {},
|
|
128
|
+
): UseMutationResult<{ success: boolean }, Error, { id: string; patch: UpdateSessionRequest }> {
|
|
129
|
+
const client = useChatClient()
|
|
130
|
+
const queryClient = useQueryClient()
|
|
131
|
+
return useMutation({
|
|
132
|
+
mutationFn: ({ id, patch }) => client.updateSession(id, patch),
|
|
133
|
+
...options,
|
|
134
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
135
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.session(variables.id) })
|
|
136
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.all })
|
|
137
|
+
return options.onSuccess?.(data, variables, context, mutation)
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The parked tool calls of one conversation.
|
|
144
|
+
*
|
|
145
|
+
* Polling is on by default while the tab is visible, and that is the point: an
|
|
146
|
+
* approval appears mid-run, from the agent's side, so a query that only ran on
|
|
147
|
+
* mount would leave the user staring at a stalled conversation. Pass
|
|
148
|
+
* `refetchInterval: false` if you drive it from the stream instead.
|
|
149
|
+
*/
|
|
150
|
+
export function usePendingApprovals(
|
|
151
|
+
conversationId: string,
|
|
152
|
+
options: ReadOptions<PendingApproval[]> = {},
|
|
153
|
+
): UseQueryResult<PendingApproval[], Error> {
|
|
154
|
+
const client = useChatClient()
|
|
155
|
+
return useQuery({
|
|
156
|
+
queryKey: chatKeys.approvals(conversationId),
|
|
157
|
+
queryFn: () => client.listPendingApprovals(conversationId),
|
|
158
|
+
enabled: Boolean(conversationId),
|
|
159
|
+
refetchInterval: 5_000,
|
|
160
|
+
...options,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function useRespondToApproval(
|
|
165
|
+
conversationId: string,
|
|
166
|
+
options: WriteOptions<
|
|
167
|
+
{ id: string; status: string },
|
|
168
|
+
{ approvalId: string; decision: ApprovalDecision }
|
|
169
|
+
> = {},
|
|
170
|
+
): UseMutationResult<
|
|
171
|
+
{ id: string; status: string },
|
|
172
|
+
Error,
|
|
173
|
+
{ approvalId: string; decision: ApprovalDecision }
|
|
174
|
+
> {
|
|
175
|
+
const client = useChatClient()
|
|
176
|
+
const queryClient = useQueryClient()
|
|
177
|
+
return useMutation({
|
|
178
|
+
mutationFn: ({ approvalId, decision }) => client.respondToApproval(approvalId, decision),
|
|
179
|
+
...options,
|
|
180
|
+
onSuccess: (data, variables, context, mutation) => {
|
|
181
|
+
void queryClient.invalidateQueries({ queryKey: chatKeys.approvals(conversationId) })
|
|
182
|
+
return options.onSuccess?.(data, variables, context, mutation)
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function useCancelRun(
|
|
188
|
+
options: WriteOptions<{ success: true }, string> = {},
|
|
189
|
+
): UseMutationResult<{ success: true }, Error, string> {
|
|
190
|
+
const client = useChatClient()
|
|
191
|
+
return useMutation({ mutationFn: (runId: string) => client.cancelRun(runId), ...options })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function useSteerRun(
|
|
195
|
+
options: WriteOptions<{ success: true }, { runId: string; message: string }> = {},
|
|
196
|
+
): UseMutationResult<{ success: true }, Error, { runId: string; message: string }> {
|
|
197
|
+
const client = useChatClient()
|
|
198
|
+
return useMutation({
|
|
199
|
+
mutationFn: ({ runId, message }) => client.steerRun(runId, message),
|
|
200
|
+
...options,
|
|
201
|
+
})
|
|
202
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
3
|
+
|
|
4
|
+
import { ChatClient } from './chat-client'
|
|
5
|
+
import { FronteraChatContext } from './hooks'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plug chat into `createFronteraApp` — Frontera Apps only:
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* createFronteraApp(<App />, { providers: [blueprintProvider, chatProvider] })
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* External applications don't use this: they construct their own
|
|
15
|
+
* `FronteraClient` and either use `ChatClient` directly or wrap
|
|
16
|
+
* `FronteraChatContext.Provider` themselves. The client is rebuilt whenever
|
|
17
|
+
* the host rotates the credential, same as `blueprintProvider`.
|
|
18
|
+
*/
|
|
19
|
+
export function chatProvider(value: { client: FronteraClient }, children: ReactNode): ReactNode {
|
|
20
|
+
return (
|
|
21
|
+
<FronteraChatContext.Provider value={new ChatClient(value.client)}>
|
|
22
|
+
{children}
|
|
23
|
+
</FronteraChatContext.Provider>
|
|
24
|
+
)
|
|
25
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
2
|
+
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
3
|
+
|
|
4
|
+
import type { StartChatRequest, StartChatResult } from './types'
|
|
5
|
+
|
|
6
|
+
export interface ChatStreamOptions {
|
|
7
|
+
signal?: AbortSignal
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start a run. The returned `response.body` is an AI SDK v6 UI-message SSE
|
|
12
|
+
* stream — hand it to `readUIMessageStream` from `ai`, or use
|
|
13
|
+
* `createChatTransport` with `useChat` instead of calling this directly.
|
|
14
|
+
*
|
|
15
|
+
* The server interleaves `: ping` SSE comment frames every 15s as keepalive;
|
|
16
|
+
* standard SSE parsers (including the AI SDK's) ignore them.
|
|
17
|
+
*/
|
|
18
|
+
export async function startChat(
|
|
19
|
+
client: FronteraClient,
|
|
20
|
+
request: StartChatRequest,
|
|
21
|
+
options: ChatStreamOptions = {},
|
|
22
|
+
): Promise<StartChatResult> {
|
|
23
|
+
const response = await client.requestRaw('/v1/chat', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
body: request,
|
|
26
|
+
signal: options.signal,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const conversationId = response.headers.get('x-conversation-id')
|
|
30
|
+
const runId = response.headers.get('x-workflow-run-id')
|
|
31
|
+
if (!conversationId || !runId) {
|
|
32
|
+
throw new FronteraError('chat response is missing run identification headers', {
|
|
33
|
+
code: 'PROTOCOL_ERROR',
|
|
34
|
+
status: response.status,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
conversationId,
|
|
40
|
+
runId,
|
|
41
|
+
runtime: response.headers.get('x-agent-runtime') ?? 'pi',
|
|
42
|
+
quotaWarning: response.headers.get('x-quota-warning') ?? undefined,
|
|
43
|
+
response,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Re-attach to a running (or just-finished) run's stream. `startIndex` is the
|
|
49
|
+
* journal index to replay from — pass the count of chunks already consumed to
|
|
50
|
+
* resume without duplicates.
|
|
51
|
+
*/
|
|
52
|
+
export async function resumeStream(
|
|
53
|
+
client: FronteraClient,
|
|
54
|
+
runId: string,
|
|
55
|
+
options: { startIndex?: number } & ChatStreamOptions = {},
|
|
56
|
+
): Promise<Response> {
|
|
57
|
+
return client.requestRaw(`/v1/chat/${encodeURIComponent(runId)}/stream`, {
|
|
58
|
+
query: { startIndex: options.startIndex },
|
|
59
|
+
signal: options.signal,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chat credential that keeps itself alive.
|
|
3
|
+
*
|
|
4
|
+
* The alternative — what an integration writes by hand — is a timer that calls
|
|
5
|
+
* their own backend, plus a 401 handler, plus the knowledge that both are
|
|
6
|
+
* needed. Every one of those is a place to be subtly wrong: refresh too late
|
|
7
|
+
* and a send fails; forget the 401 path and a laptop reopened after lunch
|
|
8
|
+
* shows a chat that quietly cannot send.
|
|
9
|
+
*
|
|
10
|
+
* So the session owns it. It renews against the platform (one signature check,
|
|
11
|
+
* no round trip to the customer's backend), falls back to `getToken` when
|
|
12
|
+
* renewal is no longer allowed, and hands the current credential to whoever
|
|
13
|
+
* asks. Nothing else in the SDK needs to know a token can expire.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { FronteraClient } from '@frontera-sdk/core/client'
|
|
17
|
+
import { FronteraError } from '@frontera-sdk/core/errors'
|
|
18
|
+
|
|
19
|
+
import type { ChatToken } from './types'
|
|
20
|
+
|
|
21
|
+
export interface TokenSessionOptions {
|
|
22
|
+
apiBaseUrl: string
|
|
23
|
+
/**
|
|
24
|
+
* Fetches a token from YOUR backend. Called once at the start, and again
|
|
25
|
+
* whenever renewal is no longer possible — a session that has run past its
|
|
26
|
+
* ceiling, or a token that expired while the tab was asleep.
|
|
27
|
+
*/
|
|
28
|
+
getToken: () => Promise<ChatToken | string>
|
|
29
|
+
/**
|
|
30
|
+
* A token you already have — typically the one whose expiry told you which
|
|
31
|
+
* `apiBaseUrl` to use. Supplying it skips the opening fetch, so a mount does
|
|
32
|
+
* not mint twice for the same person.
|
|
33
|
+
*/
|
|
34
|
+
initialToken?: ChatToken
|
|
35
|
+
/**
|
|
36
|
+
* Renew this long before expiry, as a fraction of the token's lifetime.
|
|
37
|
+
* 0.8 leaves a fifth of the lifetime as slack — enough for a slow network or
|
|
38
|
+
* a suspended tab to recover without a single failed request.
|
|
39
|
+
*/
|
|
40
|
+
renewAt?: number
|
|
41
|
+
/** Called after every successful renewal. Handy for persisting the token. */
|
|
42
|
+
onToken?: (token: ChatToken) => void
|
|
43
|
+
/**
|
|
44
|
+
* Called when the session cannot be kept alive at all — both renewal and
|
|
45
|
+
* `getToken` failed. The chat is dead until something changes, and the host
|
|
46
|
+
* is the only one that can say so on screen.
|
|
47
|
+
*/
|
|
48
|
+
onExpired?: (error: unknown) => void
|
|
49
|
+
/** Test seam. Defaults to the global. */
|
|
50
|
+
fetchImpl?: typeof fetch
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TokenSession {
|
|
54
|
+
/** The client to hand to `ChatClient` and `createChatTransport`. */
|
|
55
|
+
getClient: () => FronteraClient
|
|
56
|
+
/** The credential right now, mostly for diagnostics. */
|
|
57
|
+
current: () => ChatToken | null
|
|
58
|
+
/**
|
|
59
|
+
* Force a refresh — call it when a request comes back `UNAUTHORIZED`. Safe to
|
|
60
|
+
* call concurrently: overlapping callers share one in-flight refresh rather
|
|
61
|
+
* than racing to mint several tokens for the same person.
|
|
62
|
+
*/
|
|
63
|
+
refresh: () => Promise<ChatToken>
|
|
64
|
+
/** Stop the timer. Call it when the chat unmounts. */
|
|
65
|
+
dispose: () => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEFAULT_RENEW_AT = 0.8
|
|
69
|
+
/** Never schedule closer than this; a pathologically short token would
|
|
70
|
+
* otherwise spin the timer. */
|
|
71
|
+
const MIN_DELAY_MS = 5_000
|
|
72
|
+
|
|
73
|
+
function asToken(value: ChatToken | string): ChatToken {
|
|
74
|
+
return typeof value === 'string'
|
|
75
|
+
? // A bare string has no expiry to plan around. Treated as one hour — the
|
|
76
|
+
// platform default — so the session still refreshes rather than waiting
|
|
77
|
+
// for a failure. Return the full object from your backend to be exact.
|
|
78
|
+
{ token: value, expiresAt: new Date(Date.now() + 3600_000).toISOString() }
|
|
79
|
+
: value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createTokenSession(options: TokenSessionOptions): TokenSession {
|
|
83
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
84
|
+
const renewAt = options.renewAt ?? DEFAULT_RENEW_AT
|
|
85
|
+
|
|
86
|
+
let token: ChatToken | null = null
|
|
87
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
88
|
+
let inFlight: Promise<ChatToken> | null = null
|
|
89
|
+
let disposed = false
|
|
90
|
+
|
|
91
|
+
const client = () =>
|
|
92
|
+
new FronteraClient(
|
|
93
|
+
{
|
|
94
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
95
|
+
credential: { kind: 'token', token: token?.token ?? '' },
|
|
96
|
+
},
|
|
97
|
+
fetchImpl,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const schedule = (current: ChatToken) => {
|
|
101
|
+
if (timer) clearTimeout(timer)
|
|
102
|
+
if (disposed) return
|
|
103
|
+
const lifetime = new Date(current.expiresAt).getTime() - Date.now()
|
|
104
|
+
if (!Number.isFinite(lifetime)) return
|
|
105
|
+
const delay = Math.max(MIN_DELAY_MS, lifetime * renewAt)
|
|
106
|
+
timer = setTimeout(() => {
|
|
107
|
+
void refresh().catch(() => {
|
|
108
|
+
// `refresh` already reported through onExpired; swallowing here keeps
|
|
109
|
+
// an unhandled rejection out of the host's console for a condition it
|
|
110
|
+
// has already been told about.
|
|
111
|
+
})
|
|
112
|
+
}, delay)
|
|
113
|
+
// A timer must never hold a Node process open on its own account.
|
|
114
|
+
;(timer as unknown as { unref?: () => void }).unref?.()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Renew against the platform; the customer's backend is not involved. */
|
|
118
|
+
const renew = async (): Promise<ChatToken> => {
|
|
119
|
+
if (!token) throw new Error('no token to renew')
|
|
120
|
+
const response = await fetchImpl(`${options.apiBaseUrl}/v1/chat-tokens/renew`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { authorization: `Bearer ${token.token}` },
|
|
123
|
+
})
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new FronteraError(`renewal refused with ${response.status}`, {
|
|
126
|
+
code: response.status === 401 ? 'UNAUTHORIZED' : 'RENEWAL_FAILED',
|
|
127
|
+
status: response.status,
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
const body = (await response.json()) as { data?: ChatToken } & ChatToken
|
|
131
|
+
return body.data ?? body
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const refresh = (): Promise<ChatToken> => {
|
|
135
|
+
// One refresh at a time. Three components noticing an expiry in the same
|
|
136
|
+
// tick must not mint three tokens — and the two that lose the race would
|
|
137
|
+
// otherwise keep using a credential the third has already replaced.
|
|
138
|
+
inFlight ??= (async () => {
|
|
139
|
+
try {
|
|
140
|
+
let next: ChatToken
|
|
141
|
+
try {
|
|
142
|
+
next = token ? await renew() : asToken(await options.getToken())
|
|
143
|
+
} catch {
|
|
144
|
+
// Renewal is the fast path, not the only one: a session past its
|
|
145
|
+
// ceiling, or a token that expired while the tab slept, is exactly
|
|
146
|
+
// when the customer's backend should be asked again.
|
|
147
|
+
next = asToken(await options.getToken())
|
|
148
|
+
}
|
|
149
|
+
token = next
|
|
150
|
+
options.onToken?.(next)
|
|
151
|
+
schedule(next)
|
|
152
|
+
return next
|
|
153
|
+
} catch (error) {
|
|
154
|
+
options.onExpired?.(error)
|
|
155
|
+
throw error
|
|
156
|
+
} finally {
|
|
157
|
+
inFlight = null
|
|
158
|
+
}
|
|
159
|
+
})()
|
|
160
|
+
return inFlight
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (options.initialToken) {
|
|
164
|
+
token = options.initialToken
|
|
165
|
+
schedule(token)
|
|
166
|
+
} else {
|
|
167
|
+
// Start immediately so the first render has a credential in flight rather
|
|
168
|
+
// than a client pointing at an empty token.
|
|
169
|
+
void refresh().catch(() => {})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
getClient: client,
|
|
174
|
+
current: () => token,
|
|
175
|
+
refresh,
|
|
176
|
+
dispose: () => {
|
|
177
|
+
disposed = true
|
|
178
|
+
if (timer) clearTimeout(timer)
|
|
179
|
+
timer = null
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { DefaultChatTransport, type ChatTransport, type UIMessage } from 'ai'
|
|
2
|
+
import type { FronteraClient } from '@frontera-sdk/core/client'
|
|
3
|
+
import { authHeaders } from '@frontera-sdk/core/transport'
|
|
4
|
+
import { errorFromResponse } from '@frontera-sdk/core/errors'
|
|
5
|
+
import { chatDoctor } from './doctor'
|
|
6
|
+
|
|
7
|
+
import type { StartChatRequest } from './types'
|
|
8
|
+
|
|
9
|
+
export interface ChatTransportOptions {
|
|
10
|
+
/** Continue an existing conversation. Omit to let the server create one. */
|
|
11
|
+
conversationId?: string
|
|
12
|
+
/**
|
|
13
|
+
* Send only the last user message (default true). The server rehydrates the
|
|
14
|
+
* rest of the conversation from its own store, which keeps request bodies
|
|
15
|
+
* small — the platform web app does the same.
|
|
16
|
+
*/
|
|
17
|
+
lastMessageOnly?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* Extra body fields merged into every send (agentName, modelMode, …).
|
|
20
|
+
*
|
|
21
|
+
* Pass a FUNCTION for anything that can change while the chat is mounted.
|
|
22
|
+
* An object is read once, at construction: `useChat` keeps the transport it
|
|
23
|
+
* was first handed, so rebuilding this one with new options never reaches
|
|
24
|
+
* the request, and the send goes out with whatever was true at first render.
|
|
25
|
+
* That is how a conversation ends up with a different agent than the one on
|
|
26
|
+
* screen.
|
|
27
|
+
*/
|
|
28
|
+
body?:
|
|
29
|
+
| Partial<Omit<StartChatRequest, 'messages' | 'conversationId'>>
|
|
30
|
+
| (() => Partial<Omit<StartChatRequest, 'messages' | 'conversationId'>>)
|
|
31
|
+
/**
|
|
32
|
+
* Fired once per run with the identifiers from the response headers. The
|
|
33
|
+
* `runId` is what `resumeStream`/`cancelRun`/`steerRun` take, and
|
|
34
|
+
* `conversationId` is how a fresh conversation learns its id.
|
|
35
|
+
*/
|
|
36
|
+
onStart?: (info: {
|
|
37
|
+
conversationId: string
|
|
38
|
+
runId: string
|
|
39
|
+
runtime: string
|
|
40
|
+
quotaWarning?: string
|
|
41
|
+
}) => void
|
|
42
|
+
/**
|
|
43
|
+
* Called once when a request comes back `401`, before it is retried.
|
|
44
|
+
*
|
|
45
|
+
* Pass `session.refresh` from `createTokenSession` and an expired credential
|
|
46
|
+
* stops being a visible failure: the send is replayed with the new token and
|
|
47
|
+
* the person never learns there was one. Without it, a chat left open past
|
|
48
|
+
* the token's lifetime fails on the next message with nothing on screen
|
|
49
|
+
* explaining why.
|
|
50
|
+
*/
|
|
51
|
+
onUnauthorized?: () => Promise<unknown>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The body of a failed response, as whatever it turns out to be.
|
|
56
|
+
*
|
|
57
|
+
* A gateway between the caller and the service answers HTML, and a dropped
|
|
58
|
+
* connection answers nothing at all; neither must turn a 502 into a parse
|
|
59
|
+
* error that hides the status the caller needed to see.
|
|
60
|
+
*/
|
|
61
|
+
async function readErrorBody(response: Response): Promise<unknown> {
|
|
62
|
+
try {
|
|
63
|
+
const text = await response.text()
|
|
64
|
+
if (!text) return undefined
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text)
|
|
67
|
+
} catch {
|
|
68
|
+
return text
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An AI SDK `ChatTransport` for `useChat` that speaks the platform's chat
|
|
77
|
+
* wire protocol: auth from the `FronteraClient`'s credential, last-message-only
|
|
78
|
+
* sends, and reconnects via the run's replay stream.
|
|
79
|
+
*
|
|
80
|
+
* Browser use requires a browser-safe credential (an end-user `cht_` token, or
|
|
81
|
+
* an `apiBaseUrl` pointing at your own proxy) — never ship a `sak_` key to a
|
|
82
|
+
* browser.
|
|
83
|
+
*/
|
|
84
|
+
export function createChatTransport(
|
|
85
|
+
client: FronteraClient | (() => FronteraClient),
|
|
86
|
+
options: ChatTransportOptions = {},
|
|
87
|
+
): ChatTransport<UIMessage> {
|
|
88
|
+
// A getter is accepted because end-user tokens are short-lived BY DESIGN: an
|
|
89
|
+
// integration refreshes them mid-session, and a transport that captured the
|
|
90
|
+
// client at construction would keep signing with the expired credential
|
|
91
|
+
// until it was rebuilt — which drops any stream in flight. Reading the
|
|
92
|
+
// client per request makes rotation invisible.
|
|
93
|
+
const current = typeof client === 'function' ? client : () => client
|
|
94
|
+
let conversationId = options.conversationId
|
|
95
|
+
let runId: string | undefined
|
|
96
|
+
|
|
97
|
+
const wrappedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
98
|
+
const send = async () => {
|
|
99
|
+
const merged: Record<string, string> = {}
|
|
100
|
+
new Headers(init?.headers).forEach((value, name) => {
|
|
101
|
+
merged[name] = value
|
|
102
|
+
})
|
|
103
|
+
// Read per attempt, not once: a refresh between the two swaps the
|
|
104
|
+
// credential, and replaying with the old one would fail identically.
|
|
105
|
+
Object.assign(merged, authHeaders(current().config))
|
|
106
|
+
return fetch(input, { ...init, headers: merged })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let response = await send()
|
|
110
|
+
|
|
111
|
+
// Exactly one retry, and only for 401. A second attempt after a refusal
|
|
112
|
+
// that refreshing cannot fix is just a slower failure — and a loop here
|
|
113
|
+
// would hammer the mint path with a credential that will never work.
|
|
114
|
+
if (response.status === 401) {
|
|
115
|
+
if (options.onUnauthorized) {
|
|
116
|
+
await options.onUnauthorized()
|
|
117
|
+
response = await send()
|
|
118
|
+
} else {
|
|
119
|
+
// Said once, immediately: without a refresh wired, the next send fails
|
|
120
|
+
// the same way, and the person is looking at a chat that has silently
|
|
121
|
+
// stopped accepting messages.
|
|
122
|
+
chatDoctor.noticed('unauthorized')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/*
|
|
127
|
+
Refusals become `FronteraError` here, before the AI SDK sees them.
|
|
128
|
+
|
|
129
|
+
Left alone, a non-2xx reaches `useChat` as a bare `Error` whose message
|
|
130
|
+
is the response body — so "quota exhausted", "your token expired" and
|
|
131
|
+
"this agent was deleted" all arrive as one indistinguishable string, and
|
|
132
|
+
an integration cannot tell the user which of them happened or decide to
|
|
133
|
+
refresh the token. The service already answers with a coded envelope;
|
|
134
|
+
this keeps the code instead of discarding it.
|
|
135
|
+
*/
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw errorFromResponse(response.status, await readErrorBody(response))
|
|
138
|
+
}
|
|
139
|
+
const headerConversationId = response.headers.get('x-conversation-id')
|
|
140
|
+
const headerRunId = response.headers.get('x-workflow-run-id')
|
|
141
|
+
if (headerConversationId && headerRunId) {
|
|
142
|
+
conversationId = headerConversationId
|
|
143
|
+
runId = headerRunId
|
|
144
|
+
options.onStart?.({
|
|
145
|
+
conversationId: headerConversationId,
|
|
146
|
+
runId: headerRunId,
|
|
147
|
+
runtime: response.headers.get('x-agent-runtime') ?? 'pi',
|
|
148
|
+
quotaWarning: response.headers.get('x-quota-warning') ?? undefined,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
// Development only, and a no-op otherwise: watches the stream for the two
|
|
152
|
+
// things that park a run, so an unhandled one becomes a console warning
|
|
153
|
+
// rather than a conversation that appears to have stopped for no reason.
|
|
154
|
+
return chatDoctor.observe(response)
|
|
155
|
+
}) as typeof fetch
|
|
156
|
+
|
|
157
|
+
return new DefaultChatTransport<UIMessage>({
|
|
158
|
+
// Resolved per request, not here. The client may not exist yet: a caller
|
|
159
|
+
// that fetches its token asynchronously has nothing to give until the
|
|
160
|
+
// first exchange returns, and reading `.config` at construction would
|
|
161
|
+
// throw before anything could render.
|
|
162
|
+
api: '',
|
|
163
|
+
fetch: wrappedFetch,
|
|
164
|
+
/*
|
|
165
|
+
`body` here is the PER-CALL one — `sendMessage(message, { body })`.
|
|
166
|
+
|
|
167
|
+
It was dropped, and the loss was silent: attachments upload fine, the
|
|
168
|
+
composer shows them, the send succeeds, and the agent simply never
|
|
169
|
+
receives the file. Anything a caller attaches to one message travels this
|
|
170
|
+
way, so the same omission also swallowed per-send `modelMode` and any
|
|
171
|
+
custom field a host passes for a single turn.
|
|
172
|
+
|
|
173
|
+
Merged AFTER the transport-level `options.body` on purpose: that one is
|
|
174
|
+
the standing configuration for every send, and a value supplied for one
|
|
175
|
+
message is the more specific statement.
|
|
176
|
+
*/
|
|
177
|
+
prepareSendMessagesRequest: ({ messages, body }) => {
|
|
178
|
+
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
|
|
179
|
+
const trimmed =
|
|
180
|
+
options.lastMessageOnly === false
|
|
181
|
+
? messages
|
|
182
|
+
: lastUserMessage
|
|
183
|
+
? [lastUserMessage]
|
|
184
|
+
: messages.slice(-1)
|
|
185
|
+
return {
|
|
186
|
+
api: `${current().config.apiBaseUrl}/v1/chat`,
|
|
187
|
+
body: {
|
|
188
|
+
messages: trimmed,
|
|
189
|
+
...(conversationId ? { conversationId } : {}),
|
|
190
|
+
...(typeof options.body === 'function' ? options.body() : options.body),
|
|
191
|
+
...body,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
prepareReconnectToStreamRequest: () => {
|
|
196
|
+
if (!runId) throw new Error('no run to reconnect to — no send has completed yet')
|
|
197
|
+
return { api: `${current().config.apiBaseUrl}/v1/chat/${encodeURIComponent(runId)}/stream` }
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
}
|