@tanstack/ai-client 0.31.1 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -16
- package/dist/esm/byok/client.d.ts +5 -0
- package/dist/esm/byok/client.js +19 -2
- package/dist/esm/byok/client.js.map +1 -1
- package/dist/esm/chat-client.d.ts +31 -0
- package/dist/esm/chat-client.js +137 -10
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +25 -4
- package/dist/esm/connection-adapters.js +31 -12
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/generation-client.js +2 -1
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/types.d.ts +9 -0
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.js +2 -1
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +2 -2
- package/src/byok/client.ts +23 -2
- package/src/chat-client.ts +209 -8
- package/src/connection-adapters.ts +92 -14
- package/src/generation-client.ts +2 -0
- package/src/types.ts +9 -0
- package/src/video-generation-client.ts +2 -0
package/src/chat-client.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ByokMissingError,
|
|
15
15
|
ByokUnresolvedProviderError,
|
|
16
16
|
} from '@tanstack/ai/byok'
|
|
17
|
+
import { byokFallbackProviderId } from './byok/client'
|
|
17
18
|
import {
|
|
18
19
|
prepareResolvedByokHeaders,
|
|
19
20
|
resolveByokProviderId,
|
|
@@ -39,6 +40,7 @@ import type {
|
|
|
39
40
|
} from '@tanstack/ai/client'
|
|
40
41
|
import type { ByokClient } from './byok'
|
|
41
42
|
import type {
|
|
43
|
+
ChatHydrateOptions,
|
|
42
44
|
ChatHydrationResult,
|
|
43
45
|
ConnectionAdapter,
|
|
44
46
|
SubscribeConnectionAdapter,
|
|
@@ -330,6 +332,9 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
|
|
|
330
332
|
'MESSAGES_SNAPSHOT',
|
|
331
333
|
])
|
|
332
334
|
|
|
335
|
+
// Cap parent-chain walks so a cyclic replayed parentRunId cannot loop.
|
|
336
|
+
const MAX_RUN_LINEAGE_DEPTH = 64
|
|
337
|
+
|
|
333
338
|
export class ChatClient<
|
|
334
339
|
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
335
340
|
TContext = unknown,
|
|
@@ -352,6 +357,9 @@ export class ChatClient<
|
|
|
352
357
|
// run, so approvals/client-tool results can be sent back. Cleared when the
|
|
353
358
|
// run terminates. This is STATE (interrupt) resume, not delivery/cursor.
|
|
354
359
|
private lastResume: ChatResumeState | null = null
|
|
360
|
+
// Replay can deliver a parent pause after a descendant already finished.
|
|
361
|
+
private readonly runParents = new Map<string, string>()
|
|
362
|
+
private readonly supersededInterruptRunIds = new Set<string>()
|
|
355
363
|
// The in-flight run id already handed to `resumeInFlightRun`, so a persisted
|
|
356
364
|
// run is rejoined at most once even when both the sync read and the async
|
|
357
365
|
// hydrate surface the same resume pointer.
|
|
@@ -456,6 +464,16 @@ export class ChatClient<
|
|
|
456
464
|
/** Constructor inputs `attach()` needs on every re-attach, not just the first. */
|
|
457
465
|
private readonly rejoinRunId: string | null | undefined
|
|
458
466
|
private readonly cachesMessages: boolean
|
|
467
|
+
/**
|
|
468
|
+
* Newest-window size from `history.pageSize`. Only set when
|
|
469
|
+
* `persistence === true`.
|
|
470
|
+
*/
|
|
471
|
+
private readonly historyPageSize: number | undefined
|
|
472
|
+
private hasOlderMessages = false
|
|
473
|
+
private olderMessagesCursor: string | undefined
|
|
474
|
+
private readonly knownServerMessageIds = new Set<string>()
|
|
475
|
+
private loadOlderMessagesInFlight = false
|
|
476
|
+
private historyGeneration = 0
|
|
459
477
|
private devtoolsMounted = false
|
|
460
478
|
|
|
461
479
|
private readonly callbacksRef: {
|
|
@@ -511,6 +529,7 @@ export class ChatClient<
|
|
|
511
529
|
)
|
|
512
530
|
}
|
|
513
531
|
cachesMessages = false
|
|
532
|
+
this.historyPageSize = options.history?.pageSize
|
|
514
533
|
} else if (options.persistence) {
|
|
515
534
|
// A storage adapter: keep the combined record (transcript + resume pointer)
|
|
516
535
|
// in the browser. Persistence keys on `threadId` (the conversation
|
|
@@ -526,6 +545,9 @@ export class ChatClient<
|
|
|
526
545
|
(messages) => this.processor.setMessages(messages),
|
|
527
546
|
(snapshot) => this.applyPersistedResume(snapshot),
|
|
528
547
|
)
|
|
548
|
+
this.historyPageSize = undefined
|
|
549
|
+
} else {
|
|
550
|
+
this.historyPageSize = undefined
|
|
529
551
|
}
|
|
530
552
|
// Both `body` (deprecated) and `forwardedProps` populate the AG-UI
|
|
531
553
|
// `RunAgentInput.forwardedProps` wire field. They are stored
|
|
@@ -1038,13 +1060,18 @@ export class ChatClient<
|
|
|
1038
1060
|
if (!hydrate) return
|
|
1039
1061
|
if (this.isLoading || this.abortController) return
|
|
1040
1062
|
if (this.disposed) return
|
|
1063
|
+
const pageSize = this.historyPageSize
|
|
1064
|
+
const hydrateOptions: ChatHydrateOptions | undefined =
|
|
1065
|
+
pageSize === undefined ? undefined : { limit: pageSize }
|
|
1041
1066
|
void (async () => {
|
|
1042
1067
|
let result: ChatHydrationResult
|
|
1068
|
+
const generation = this.historyGeneration
|
|
1043
1069
|
try {
|
|
1044
|
-
result = await hydrate(this.threadId)
|
|
1070
|
+
result = await hydrate(this.threadId, hydrateOptions)
|
|
1045
1071
|
} catch {
|
|
1046
1072
|
return
|
|
1047
1073
|
}
|
|
1074
|
+
if (generation !== this.historyGeneration) return
|
|
1048
1075
|
// NO VIEW IS WATCHING ANY MORE (it unmounted while this fetch was in
|
|
1049
1076
|
// flight). Applying anything now is pointless, and one thing is actively
|
|
1050
1077
|
// harmful: the branch below calls `maybeRejoinInFlight`, which opens a TAIL.
|
|
@@ -1057,8 +1084,11 @@ export class ChatClient<
|
|
|
1057
1084
|
if (this.disposed || !this.tailing) return
|
|
1058
1085
|
// A send may have started while the fetch was in flight — don't stomp it.
|
|
1059
1086
|
if (this.isLoading || this.abortController) return
|
|
1087
|
+
this.applyHydrationPage(result.page)
|
|
1060
1088
|
if (result.messages.length > 0) {
|
|
1061
|
-
|
|
1089
|
+
const windowMessages = normalizeMessagesDates(result.messages)
|
|
1090
|
+
this.processor.setMessages(windowMessages)
|
|
1091
|
+
this.rememberServerMessageIds(windowMessages)
|
|
1062
1092
|
}
|
|
1063
1093
|
if (result.interrupts && result.interrupts.pending.length > 0) {
|
|
1064
1094
|
// Pending interrupt = the thread is paused awaiting a human decision, so
|
|
@@ -1134,14 +1164,30 @@ export class ChatClient<
|
|
|
1134
1164
|
): void {
|
|
1135
1165
|
if (chunk.type === 'RUN_STARTED') {
|
|
1136
1166
|
const chunkRunId = getChunkRunId(chunk) ?? chunk.runId
|
|
1167
|
+
const parentRunId =
|
|
1168
|
+
'parentRunId' in chunk && typeof chunk.parentRunId === 'string'
|
|
1169
|
+
? chunk.parentRunId
|
|
1170
|
+
: undefined
|
|
1171
|
+
if (parentRunId) {
|
|
1172
|
+
this.runParents.set(chunkRunId, parentRunId)
|
|
1173
|
+
if (this.supersededInterruptRunIds.has(chunkRunId)) {
|
|
1174
|
+
this.markLineageAnswered(parentRunId)
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1137
1177
|
this.activeResumeThreadId =
|
|
1138
1178
|
'threadId' in chunk && typeof chunk.threadId === 'string'
|
|
1139
1179
|
? chunk.threadId
|
|
1140
1180
|
: this.activeResumeThreadId
|
|
1141
1181
|
this.activeResumeRunId = chunkRunId
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1182
|
+
// Late RUN_STARTED for a run that already finished is replay, not a live
|
|
1183
|
+
// turn. A new send can reuse a finished run id while isLoading is true.
|
|
1184
|
+
const isAnsweredReplayStart =
|
|
1185
|
+
this.supersededInterruptRunIds.has(chunkRunId) && !this.isLoading
|
|
1186
|
+
if (!isAnsweredReplayStart) {
|
|
1187
|
+
this.activeRunIds.add(chunkRunId)
|
|
1188
|
+
this.clearedStreamTracker.onRunStarted(chunkRunId)
|
|
1189
|
+
this.setSessionGenerating(true)
|
|
1190
|
+
}
|
|
1145
1191
|
// Persist a live-run resume snapshot so a full page reload can rejoin this
|
|
1146
1192
|
// in-flight run via joinRun. Only a persistor writes it, and a persistor
|
|
1147
1193
|
// exists only in client-authoritative mode; server-authoritative reconnect
|
|
@@ -1149,12 +1195,25 @@ export class ChatClient<
|
|
|
1149
1195
|
// client-cached run pointer (which goes stale the moment a turn spans a
|
|
1150
1196
|
// second run) is ever written. Interrupt/terminal handling overwrites or
|
|
1151
1197
|
// clears it in observeInterruptState.
|
|
1152
|
-
if (
|
|
1198
|
+
if (
|
|
1199
|
+
this.persistor &&
|
|
1200
|
+
this.connection.joinRun &&
|
|
1201
|
+
!this.lastResume &&
|
|
1202
|
+
!isAnsweredReplayStart
|
|
1203
|
+
) {
|
|
1153
1204
|
this.persistResumeSnapshot({
|
|
1154
1205
|
threadId: this.activeResumeThreadId ?? this.threadId,
|
|
1155
1206
|
runId: chunkRunId,
|
|
1156
1207
|
})
|
|
1157
1208
|
}
|
|
1209
|
+
if (
|
|
1210
|
+
this.lastResume &&
|
|
1211
|
+
this.supersededInterruptRunIds.has(this.lastResume.runId)
|
|
1212
|
+
) {
|
|
1213
|
+
this.lastResume = null
|
|
1214
|
+
this.persistor?.persistResumeSnapshot(null)
|
|
1215
|
+
this.interruptManager.reset()
|
|
1216
|
+
}
|
|
1158
1217
|
return
|
|
1159
1218
|
}
|
|
1160
1219
|
|
|
@@ -1199,11 +1258,29 @@ export class ChatClient<
|
|
|
1199
1258
|
? chunk.threadId
|
|
1200
1259
|
: this.activeResumeThreadId
|
|
1201
1260
|
|
|
1261
|
+
// Record answered lineage before hydrating a pause. Intermediate
|
|
1262
|
+
// tool_calls RUN_FINISHED is a mid-turn handoff, not an answered pause.
|
|
1263
|
+
if (
|
|
1264
|
+
runId &&
|
|
1265
|
+
!isIntermediateToolTurn(chunk) &&
|
|
1266
|
+
(chunk.type === 'RUN_ERROR' ||
|
|
1267
|
+
(chunk.type === 'RUN_FINISHED' && chunk.outcome?.type !== 'interrupt'))
|
|
1268
|
+
) {
|
|
1269
|
+
this.markLineageAnswered(runId)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1202
1272
|
if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') {
|
|
1203
1273
|
// Track the REQUEST run id (what the client sent) so a resume targets the
|
|
1204
1274
|
// same run even when provider events carry their own run id.
|
|
1205
1275
|
const interruptedRunId =
|
|
1206
1276
|
this.currentRunId ?? runId ?? this.activeResumeRunId ?? ''
|
|
1277
|
+
// Replay re-emits a pause that a continuation already answered.
|
|
1278
|
+
if (
|
|
1279
|
+
interruptedRunId &&
|
|
1280
|
+
this.supersededInterruptRunIds.has(interruptedRunId)
|
|
1281
|
+
) {
|
|
1282
|
+
return
|
|
1283
|
+
}
|
|
1207
1284
|
this.lastResume = {
|
|
1208
1285
|
threadId: threadId ?? this.threadId,
|
|
1209
1286
|
runId: interruptedRunId,
|
|
@@ -1248,13 +1325,20 @@ export class ChatClient<
|
|
|
1248
1325
|
chunk.type === 'RUN_FINISHED' &&
|
|
1249
1326
|
chunk.outcome?.type !== 'interrupt',
|
|
1250
1327
|
)
|
|
1328
|
+
// Replay can deliver a parent pause after a descendant already finished,
|
|
1329
|
+
// and this terminal's run id may not match lastResume.
|
|
1330
|
+
const isLineageDescendantTerminal = Boolean(
|
|
1331
|
+
this.lastResume &&
|
|
1332
|
+
this.supersededInterruptRunIds.has(this.lastResume.runId),
|
|
1333
|
+
)
|
|
1251
1334
|
if (
|
|
1252
1335
|
isRunlessSessionError ||
|
|
1253
1336
|
isTrackedRunTerminal ||
|
|
1254
1337
|
isCurrentRunTerminal ||
|
|
1255
1338
|
isActiveStreamRunTerminal ||
|
|
1256
1339
|
isCurrentStreamTerminal ||
|
|
1257
|
-
isActiveInterruptSubmissionTerminal
|
|
1340
|
+
isActiveInterruptSubmissionTerminal ||
|
|
1341
|
+
isLineageDescendantTerminal
|
|
1258
1342
|
) {
|
|
1259
1343
|
this.lastResume = null
|
|
1260
1344
|
// Run settled without an interrupt: drop the durable resume snapshot so a
|
|
@@ -1266,6 +1350,18 @@ export class ChatClient<
|
|
|
1266
1350
|
this.notifyResumeStateChange('live')
|
|
1267
1351
|
}
|
|
1268
1352
|
|
|
1353
|
+
/** Walk parentRunId links and mark this run plus its ancestors as answered. */
|
|
1354
|
+
private markLineageAnswered(runId: string): void {
|
|
1355
|
+
let current: string | undefined = runId
|
|
1356
|
+
let hops = 0
|
|
1357
|
+
while (current !== undefined && hops < MAX_RUN_LINEAGE_DEPTH) {
|
|
1358
|
+
if (this.supersededInterruptRunIds.has(current)) return
|
|
1359
|
+
this.supersededInterruptRunIds.add(current)
|
|
1360
|
+
current = this.runParents.get(current)
|
|
1361
|
+
hops++
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1269
1365
|
/**
|
|
1270
1366
|
* The interrupt-resume state for the active/interrupted run (its run/thread
|
|
1271
1367
|
* ids), or null when there is nothing to resume. Apps can persist this to
|
|
@@ -2262,7 +2358,7 @@ export class ChatClient<
|
|
|
2262
2358
|
|
|
2263
2359
|
try {
|
|
2264
2360
|
// Get UIMessages with parts (preserves approval state and client tool results)
|
|
2265
|
-
const messages = this.processor.getMessages()
|
|
2361
|
+
const messages = this.messagesForSend(this.processor.getMessages())
|
|
2266
2362
|
const clientTools = new Map(this.clientToolsRef.current)
|
|
2267
2363
|
const runtimeContext = this.context
|
|
2268
2364
|
|
|
@@ -2327,6 +2423,7 @@ export class ChatClient<
|
|
|
2327
2423
|
const provider = resolveByokProviderId(
|
|
2328
2424
|
this.byokProvider,
|
|
2329
2425
|
mergedBody.provider,
|
|
2426
|
+
byokFallbackProviderId(this.byok),
|
|
2330
2427
|
)
|
|
2331
2428
|
byokHeaders = await prepareResolvedByokHeaders(this.byok, provider)
|
|
2332
2429
|
}
|
|
@@ -2484,6 +2581,7 @@ export class ChatClient<
|
|
|
2484
2581
|
await this.drainPostStreamActions()
|
|
2485
2582
|
|
|
2486
2583
|
if (streamCompletedSuccessfully) {
|
|
2584
|
+
this.rememberProcessorMessageIds()
|
|
2487
2585
|
if (this.status !== 'ready') {
|
|
2488
2586
|
// Terminal run, but onStreamEnd never fired: the processor had
|
|
2489
2587
|
// no assistant message to emit it for (e.g. a bare
|
|
@@ -2614,6 +2712,7 @@ export class ChatClient<
|
|
|
2614
2712
|
// remove the stored conversation outright.
|
|
2615
2713
|
this.persistor?.beginClear()
|
|
2616
2714
|
this.processor.clearMessages()
|
|
2715
|
+
this.resetHistoryPaging()
|
|
2617
2716
|
this.discardPendingSends()
|
|
2618
2717
|
this.persistor?.remove()
|
|
2619
2718
|
this.lastResume = null
|
|
@@ -2875,6 +2974,108 @@ export class ChatClient<
|
|
|
2875
2974
|
return this.processor.getMessages() as Array<UIMessage<TTools>>
|
|
2876
2975
|
}
|
|
2877
2976
|
|
|
2977
|
+
/**
|
|
2978
|
+
* True when the last hydrate or older-page response said more messages exist.
|
|
2979
|
+
*/
|
|
2980
|
+
getHasOlderMessages() {
|
|
2981
|
+
return this.hasOlderMessages
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
/**
|
|
2985
|
+
* Fetch the next older window and put it in front of the painted messages.
|
|
2986
|
+
*
|
|
2987
|
+
* No-op when there is no older page, no cursor, or a page load is already
|
|
2988
|
+
* running. A network failure rejects and leaves the painted messages as they
|
|
2989
|
+
* are; {@link getHasOlderMessages} stays true.
|
|
2990
|
+
*/
|
|
2991
|
+
async loadOlderMessages() {
|
|
2992
|
+
const hydrate = this.connection.hydrate
|
|
2993
|
+
if (hydrate === undefined) return
|
|
2994
|
+
if (this.disposed) return
|
|
2995
|
+
if (!this.hasOlderMessages) return
|
|
2996
|
+
const cursor = this.olderMessagesCursor
|
|
2997
|
+
if (cursor === undefined) return
|
|
2998
|
+
if (this.loadOlderMessagesInFlight) return
|
|
2999
|
+
|
|
3000
|
+
const hydrateOptions: ChatHydrateOptions = { before: cursor }
|
|
3001
|
+
if (this.historyPageSize !== undefined) {
|
|
3002
|
+
hydrateOptions.limit = this.historyPageSize
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
this.loadOlderMessagesInFlight = true
|
|
3006
|
+
const generation = this.historyGeneration
|
|
3007
|
+
try {
|
|
3008
|
+
const result = await hydrate(this.threadId, hydrateOptions)
|
|
3009
|
+
if (this.disposed) return
|
|
3010
|
+
if (generation !== this.historyGeneration) return
|
|
3011
|
+
const olderMessages = normalizeMessagesDates(result.messages)
|
|
3012
|
+
if (olderMessages.length === 0) {
|
|
3013
|
+
throw new Error('Older page was empty')
|
|
3014
|
+
}
|
|
3015
|
+
this.applyHydrationPage(result.page)
|
|
3016
|
+
this.processor.prependMessages(olderMessages)
|
|
3017
|
+
this.rememberServerMessageIds(olderMessages)
|
|
3018
|
+
} finally {
|
|
3019
|
+
this.loadOlderMessagesInFlight = false
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
private resetHistoryPaging() {
|
|
3024
|
+
this.hasOlderMessages = false
|
|
3025
|
+
this.olderMessagesCursor = undefined
|
|
3026
|
+
this.knownServerMessageIds.clear()
|
|
3027
|
+
this.historyGeneration++
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
private applyHydrationPage(page: ChatHydrationResult['page']) {
|
|
3031
|
+
if (page?.truncated === true) {
|
|
3032
|
+
this.hasOlderMessages = true
|
|
3033
|
+
this.olderMessagesCursor = page.cursor
|
|
3034
|
+
return
|
|
3035
|
+
}
|
|
3036
|
+
this.hasOlderMessages = false
|
|
3037
|
+
this.olderMessagesCursor = undefined
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
private rememberServerMessageIds(messages: Array<UIMessage>) {
|
|
3041
|
+
const ids = messages.map((message) => message.id)
|
|
3042
|
+
for (const id of ids) {
|
|
3043
|
+
this.knownServerMessageIds.add(id)
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
private rememberProcessorMessageIds() {
|
|
3048
|
+
if (this.historyPageSize === undefined) return
|
|
3049
|
+
this.rememberServerMessageIds(this.processor.getMessages())
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
private messagesForSend(messages: Array<UIMessage>) {
|
|
3053
|
+
if (this.historyPageSize === undefined) {
|
|
3054
|
+
return messages
|
|
3055
|
+
}
|
|
3056
|
+
const unknownMessages: Array<UIMessage> = []
|
|
3057
|
+
for (const message of messages) {
|
|
3058
|
+
const isKnown = this.knownServerMessageIds.has(message.id)
|
|
3059
|
+
if (isKnown) {
|
|
3060
|
+
continue
|
|
3061
|
+
}
|
|
3062
|
+
unknownMessages.push(message)
|
|
3063
|
+
}
|
|
3064
|
+
if (unknownMessages.length > 0) {
|
|
3065
|
+
return unknownMessages
|
|
3066
|
+
}
|
|
3067
|
+
// No new ids: reload has already dropped the old assistant, so this is
|
|
3068
|
+
// `[lastUser]`. Resume/continue still has the assistant, so the cutoff is
|
|
3069
|
+
// that assistant and the stored tool-call stays.
|
|
3070
|
+
const lastUserIndex = messages.findLastIndex(
|
|
3071
|
+
(message) => message.role === 'user',
|
|
3072
|
+
)
|
|
3073
|
+
if (lastUserIndex === -1) {
|
|
3074
|
+
return []
|
|
3075
|
+
}
|
|
3076
|
+
return messages.slice(lastUserIndex)
|
|
3077
|
+
}
|
|
3078
|
+
|
|
2878
3079
|
/**
|
|
2879
3080
|
* True when an interrupt (or another direct send) claimed the client during
|
|
2880
3081
|
* a drain. Read via a method so cross-await mutations are not constant-folded
|
|
@@ -531,12 +531,48 @@ function errorFromXhrStatus(xhr: XMLHttpRequest): Error {
|
|
|
531
531
|
return new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
+
function chatHydrationSearchParams(
|
|
535
|
+
threadId: string,
|
|
536
|
+
options?: ChatHydrateOptions,
|
|
537
|
+
): Record<string, string> {
|
|
538
|
+
const values: Record<string, string> = { threadId }
|
|
539
|
+
if (options?.limit !== undefined) {
|
|
540
|
+
values.limit = String(options.limit)
|
|
541
|
+
}
|
|
542
|
+
if (options?.before !== undefined) {
|
|
543
|
+
values.before = options.before
|
|
544
|
+
}
|
|
545
|
+
return values
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function readChatHydrationPage(
|
|
549
|
+
value: unknown,
|
|
550
|
+
): { truncated: false } | { truncated: true; cursor: string } {
|
|
551
|
+
if (value === null || typeof value !== 'object') {
|
|
552
|
+
return { truncated: false }
|
|
553
|
+
}
|
|
554
|
+
if (!('truncated' in value) || value.truncated !== true) {
|
|
555
|
+
return { truncated: false }
|
|
556
|
+
}
|
|
557
|
+
if (
|
|
558
|
+
!('cursor' in value) ||
|
|
559
|
+
typeof value.cursor !== 'string' ||
|
|
560
|
+
value.cursor === ''
|
|
561
|
+
) {
|
|
562
|
+
return { truncated: false }
|
|
563
|
+
}
|
|
564
|
+
return { truncated: true, cursor: value.cursor }
|
|
565
|
+
}
|
|
566
|
+
|
|
534
567
|
/**
|
|
535
568
|
* GET the hydration endpoint for a thread and parse its JSON `{ messages,
|
|
536
569
|
* activeRun }` body. This is the transport-agnostic reconnect probe: keyed on
|
|
537
570
|
* the STABLE thread id, it returns the stored transcript and — if a run is still
|
|
538
571
|
* generating — a cursor the caller tails via `joinRun`. Shared by every fetch/
|
|
539
572
|
* XHR adapter so the client never has to know which transport is in use.
|
|
573
|
+
*
|
|
574
|
+
* Pass `limit` / `before` to page. Old servers that omit `page` are treated as
|
|
575
|
+
* a full list (`truncated: false`).
|
|
540
576
|
*/
|
|
541
577
|
async function fetchThreadHydration(
|
|
542
578
|
fetchClient: typeof globalThis.fetch,
|
|
@@ -544,12 +580,16 @@ async function fetchThreadHydration(
|
|
|
544
580
|
headers: Record<string, string>,
|
|
545
581
|
credentials: RequestCredentials,
|
|
546
582
|
threadId: string,
|
|
583
|
+
options?: ChatHydrateOptions,
|
|
547
584
|
): Promise<ChatHydrationResult> {
|
|
548
|
-
const response = await fetchClient(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
585
|
+
const response = await fetchClient(
|
|
586
|
+
withSearchParams(url, chatHydrationSearchParams(threadId, options)),
|
|
587
|
+
{
|
|
588
|
+
method: 'GET',
|
|
589
|
+
headers: { Accept: 'application/json', ...headers },
|
|
590
|
+
credentials,
|
|
591
|
+
},
|
|
592
|
+
)
|
|
553
593
|
await assertResponseOk(response)
|
|
554
594
|
const data = (await response.json()) as {
|
|
555
595
|
messages?: Array<UIMessage>
|
|
@@ -558,6 +598,7 @@ async function fetchThreadHydration(
|
|
|
558
598
|
runId?: unknown
|
|
559
599
|
pending?: unknown
|
|
560
600
|
} | null
|
|
601
|
+
page?: unknown
|
|
561
602
|
}
|
|
562
603
|
const activeRun =
|
|
563
604
|
data.activeRun && typeof data.activeRun.runId === 'string'
|
|
@@ -579,6 +620,7 @@ async function fetchThreadHydration(
|
|
|
579
620
|
: [],
|
|
580
621
|
activeRun,
|
|
581
622
|
interrupts,
|
|
623
|
+
page: readChatHydrationPage(data.page),
|
|
582
624
|
}
|
|
583
625
|
}
|
|
584
626
|
|
|
@@ -881,7 +923,10 @@ export interface ConnectConnectionAdapter {
|
|
|
881
923
|
* `fetchServerSentEvents` / `fetchHttpStream`, and on `stream()` /
|
|
882
924
|
* `rpcStream()` when supplied via {@link StreamConnectionHandlers}.
|
|
883
925
|
*/
|
|
884
|
-
hydrate?: (
|
|
926
|
+
hydrate?: (
|
|
927
|
+
threadId: string,
|
|
928
|
+
options?: ChatHydrateOptions,
|
|
929
|
+
) => Promise<ChatHydrationResult>
|
|
885
930
|
}
|
|
886
931
|
|
|
887
932
|
/**
|
|
@@ -911,11 +956,23 @@ export interface GenerationHydrationResult {
|
|
|
911
956
|
activeRun: { runId: string } | null
|
|
912
957
|
}
|
|
913
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Optional paging for a chat hydrate GET.
|
|
961
|
+
* Omit both fields to load the full thread.
|
|
962
|
+
*/
|
|
963
|
+
export interface ChatHydrateOptions {
|
|
964
|
+
limit?: number
|
|
965
|
+
before?: string
|
|
966
|
+
}
|
|
967
|
+
|
|
914
968
|
/**
|
|
915
969
|
* Server-resolved hydration for a thread. `messages` is the stored transcript;
|
|
916
970
|
* `activeRun` is a cursor to a run still generating for the thread (or `null`).
|
|
917
971
|
* Keyed on the STABLE thread id — the client never handles a run id, so a turn
|
|
918
972
|
* that spans several runs (interrupt/tool continuations) reconnects correctly.
|
|
973
|
+
*
|
|
974
|
+
* `page` is additive. Old servers omit it; treat that as a full list
|
|
975
|
+
* (`truncated: false`).
|
|
919
976
|
*/
|
|
920
977
|
export interface ChatHydrationResult {
|
|
921
978
|
messages: Array<UIMessage>
|
|
@@ -929,6 +986,11 @@ export interface ChatHydrationResult {
|
|
|
929
986
|
runId: string
|
|
930
987
|
pending: Array<ChatPendingInterrupt>
|
|
931
988
|
} | null
|
|
989
|
+
/**
|
|
990
|
+
* Whether this window is a slice of a longer thread.
|
|
991
|
+
* `cursor` is set when `truncated` is true and is sent back as `before`.
|
|
992
|
+
*/
|
|
993
|
+
page?: { truncated: false } | { truncated: true; cursor: string }
|
|
932
994
|
}
|
|
933
995
|
|
|
934
996
|
/**
|
|
@@ -952,7 +1014,10 @@ export interface ResumableConnectConnectionAdapter extends ConnectConnectionAdap
|
|
|
952
1014
|
* JSON GET (`?threadId`), so it is transport-agnostic regardless of how the
|
|
953
1015
|
* delivery stream is served.
|
|
954
1016
|
*/
|
|
955
|
-
hydrate?: (
|
|
1017
|
+
hydrate?: (
|
|
1018
|
+
threadId: string,
|
|
1019
|
+
options?: ChatHydrateOptions,
|
|
1020
|
+
) => Promise<ChatHydrationResult>
|
|
956
1021
|
}
|
|
957
1022
|
|
|
958
1023
|
export interface SubscribeConnectionAdapter {
|
|
@@ -984,7 +1049,10 @@ export interface SubscribeConnectionAdapter {
|
|
|
984
1049
|
* cursor). Present only when the underlying connection supports it. The client
|
|
985
1050
|
* calls it on mount to re-hydrate without any app-side loader or prop.
|
|
986
1051
|
*/
|
|
987
|
-
hydrate?: (
|
|
1052
|
+
hydrate?: (
|
|
1053
|
+
threadId: string,
|
|
1054
|
+
options?: ChatHydrateOptions,
|
|
1055
|
+
) => Promise<ChatHydrationResult>
|
|
988
1056
|
}
|
|
989
1057
|
|
|
990
1058
|
/**
|
|
@@ -1209,7 +1277,10 @@ export function normalizeConnectionAdapter(
|
|
|
1209
1277
|
// (no non-null assertion). Present only when the connection supports it.
|
|
1210
1278
|
const hydrate = (connection as ResumableConnectConnectionAdapter).hydrate
|
|
1211
1279
|
return typeof hydrate === 'function'
|
|
1212
|
-
? {
|
|
1280
|
+
? {
|
|
1281
|
+
hydrate: (threadId: string, options?: ChatHydrateOptions) =>
|
|
1282
|
+
hydrate(threadId, options),
|
|
1283
|
+
}
|
|
1213
1284
|
: {}
|
|
1214
1285
|
})(),
|
|
1215
1286
|
}
|
|
@@ -1423,7 +1494,7 @@ export function fetchServerSentEvents(
|
|
|
1423
1494
|
resolvedOptions.reconnect,
|
|
1424
1495
|
)
|
|
1425
1496
|
},
|
|
1426
|
-
async hydrate(threadId) {
|
|
1497
|
+
async hydrate(threadId, hydrateOptions) {
|
|
1427
1498
|
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1428
1499
|
const resolvedOptions =
|
|
1429
1500
|
typeof options === 'function' ? await options() : options
|
|
@@ -1433,6 +1504,7 @@ export function fetchServerSentEvents(
|
|
|
1433
1504
|
mergeHeaders(resolvedOptions.headers),
|
|
1434
1505
|
resolvedOptions.credentials || 'same-origin',
|
|
1435
1506
|
threadId,
|
|
1507
|
+
hydrateOptions,
|
|
1436
1508
|
)
|
|
1437
1509
|
},
|
|
1438
1510
|
async hydrateGeneration(threadId) {
|
|
@@ -1579,7 +1651,7 @@ export function fetchHttpStream(
|
|
|
1579
1651
|
resolvedOptions.reconnect,
|
|
1580
1652
|
)
|
|
1581
1653
|
},
|
|
1582
|
-
async hydrate(threadId) {
|
|
1654
|
+
async hydrate(threadId, hydrateOptions) {
|
|
1583
1655
|
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1584
1656
|
const resolvedOptions =
|
|
1585
1657
|
typeof options === 'function' ? await options() : options
|
|
@@ -1589,6 +1661,7 @@ export function fetchHttpStream(
|
|
|
1589
1661
|
mergeHeaders(resolvedOptions.headers),
|
|
1590
1662
|
resolvedOptions.credentials || 'same-origin',
|
|
1591
1663
|
threadId,
|
|
1664
|
+
hydrateOptions,
|
|
1592
1665
|
)
|
|
1593
1666
|
},
|
|
1594
1667
|
async hydrateGeneration(threadId) {
|
|
@@ -1917,7 +1990,7 @@ export function xhrServerSentEvents(
|
|
|
1917
1990
|
resolvedOptions.reconnect,
|
|
1918
1991
|
)
|
|
1919
1992
|
},
|
|
1920
|
-
async hydrate(threadId) {
|
|
1993
|
+
async hydrate(threadId, hydrateOptions) {
|
|
1921
1994
|
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
1922
1995
|
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
1923
1996
|
// Hydration is a non-streaming JSON GET, so fetch is fine even for the
|
|
@@ -1928,6 +2001,7 @@ export function xhrServerSentEvents(
|
|
|
1928
2001
|
mergeHeaders(resolvedOptions.headers),
|
|
1929
2002
|
resolvedOptions.withCredentials ? 'include' : 'same-origin',
|
|
1930
2003
|
threadId,
|
|
2004
|
+
hydrateOptions,
|
|
1931
2005
|
)
|
|
1932
2006
|
},
|
|
1933
2007
|
async hydrateGeneration(threadId) {
|
|
@@ -2000,7 +2074,7 @@ export function xhrHttpStream(
|
|
|
2000
2074
|
resolvedOptions.reconnect,
|
|
2001
2075
|
)
|
|
2002
2076
|
},
|
|
2003
|
-
async hydrate(threadId) {
|
|
2077
|
+
async hydrate(threadId, hydrateOptions) {
|
|
2004
2078
|
const resolvedUrl = typeof url === 'function' ? url() : url
|
|
2005
2079
|
const resolvedOptions = await resolveXhrConnectionOptions(options)
|
|
2006
2080
|
// Hydration is a non-streaming JSON GET, so fetch is fine even for the
|
|
@@ -2011,6 +2085,7 @@ export function xhrHttpStream(
|
|
|
2011
2085
|
mergeHeaders(resolvedOptions.headers),
|
|
2012
2086
|
resolvedOptions.withCredentials ? 'include' : 'same-origin',
|
|
2013
2087
|
threadId,
|
|
2088
|
+
hydrateOptions,
|
|
2014
2089
|
)
|
|
2015
2090
|
},
|
|
2016
2091
|
async hydrateGeneration(threadId) {
|
|
@@ -2451,7 +2526,10 @@ export interface StreamConnectionHandlers {
|
|
|
2451
2526
|
* Server-driven chat hydration for `persistence: true`: the stored
|
|
2452
2527
|
* transcript for `threadId` plus a cursor to an in-flight run.
|
|
2453
2528
|
*/
|
|
2454
|
-
hydrate?: (
|
|
2529
|
+
hydrate?: (
|
|
2530
|
+
threadId: string,
|
|
2531
|
+
options?: ChatHydrateOptions,
|
|
2532
|
+
) => Promise<ChatHydrationResult>
|
|
2455
2533
|
/**
|
|
2456
2534
|
* Server-driven generation hydration for `persistence: true`: the last
|
|
2457
2535
|
* generation's resume snapshot for `threadId` plus a cursor to a run still
|
package/src/generation-client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ByokBlockedError, ByokMissingError } from '@tanstack/ai/byok'
|
|
2
|
+
import { byokFallbackProviderId } from './byok/client'
|
|
2
3
|
import {
|
|
3
4
|
prepareResolvedByokHeaders,
|
|
4
5
|
resolveByokProviderId,
|
|
@@ -280,6 +281,7 @@ export class GenerationClient<
|
|
|
280
281
|
const provider = resolveByokProviderId(
|
|
281
282
|
this.byokProvider,
|
|
282
283
|
this.body.provider,
|
|
284
|
+
byokFallbackProviderId(this.byok),
|
|
283
285
|
)
|
|
284
286
|
headers = await prepareResolvedByokHeaders(this.byok, provider)
|
|
285
287
|
}
|
package/src/types.ts
CHANGED
|
@@ -751,14 +751,23 @@ export type ChatPersistenceOptions<
|
|
|
751
751
|
| {
|
|
752
752
|
persistence: true
|
|
753
753
|
threadId: string
|
|
754
|
+
/**
|
|
755
|
+
* Newest-window size for server hydrate. Only with `persistence: true`.
|
|
756
|
+
* Without this, hydrate still loads the full thread.
|
|
757
|
+
*/
|
|
758
|
+
history?: {
|
|
759
|
+
pageSize: number
|
|
760
|
+
}
|
|
754
761
|
}
|
|
755
762
|
| {
|
|
756
763
|
persistence: ChatClientPersistence<TTools>
|
|
757
764
|
threadId: string
|
|
765
|
+
history?: never
|
|
758
766
|
}
|
|
759
767
|
| {
|
|
760
768
|
persistence?: false | undefined
|
|
761
769
|
threadId?: string
|
|
770
|
+
history?: never
|
|
762
771
|
}
|
|
763
772
|
|
|
764
773
|
type IsUnknown<T> = unknown extends T
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ByokBlockedError, ByokMissingError } from '@tanstack/ai/byok'
|
|
2
|
+
import { byokFallbackProviderId } from './byok/client'
|
|
2
3
|
import {
|
|
3
4
|
prepareResolvedByokHeaders,
|
|
4
5
|
resolveByokProviderId,
|
|
@@ -273,6 +274,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
|
|
|
273
274
|
const provider = resolveByokProviderId(
|
|
274
275
|
this.byokProvider,
|
|
275
276
|
this.body.provider,
|
|
277
|
+
byokFallbackProviderId(this.byok),
|
|
276
278
|
)
|
|
277
279
|
headers = await prepareResolvedByokHeaders(this.byok, provider)
|
|
278
280
|
}
|