@tanstack/ai-client 0.29.0 → 0.29.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/dist/esm/chat-client.d.ts +6 -0
- package/dist/esm/chat-client.js +27 -4
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.js +15 -0
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +1 -0
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +5 -1
- package/dist/esm/devtools.js +54 -10
- package/dist/esm/devtools.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +43 -8
- package/src/connection-adapters.ts +23 -0
- package/src/devtools-noop.ts +1 -0
- package/src/devtools.ts +98 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-client",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Framework-agnostic headless client for TanStack AI chat, realtime sessions, streaming transports, and media generations.",
|
|
5
5
|
"author": "Tanner Linsley",
|
|
6
6
|
"license": "MIT",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"src"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@tanstack/ai": "^0.
|
|
57
|
-
"@tanstack/ai-event-client": "^0.11.
|
|
56
|
+
"@tanstack/ai": "^0.51.0",
|
|
57
|
+
"@tanstack/ai-event-client": "^0.11.1",
|
|
58
58
|
"@tanstack/ai-utils": "^0.4.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
package/src/chat-client.ts
CHANGED
|
@@ -164,6 +164,20 @@ function resolveTransport(transport: {
|
|
|
164
164
|
throw new Error('ChatClient: either `connection` or `fetcher` is required.')
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
function connectionDrainsOnSend(connection: ConnectionAdapter): boolean {
|
|
168
|
+
return 'connect' in connection
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isIntermediateToolTurn(chunk: StreamChunk): boolean {
|
|
172
|
+
if (chunk.type !== 'RUN_FINISHED') return false
|
|
173
|
+
if (chunk.outcome?.type === 'interrupt') return false
|
|
174
|
+
const extra = chunk as StreamChunk & { finishReason?: unknown }
|
|
175
|
+
if (extra.finishReason !== undefined) {
|
|
176
|
+
return extra.finishReason === 'tool_calls'
|
|
177
|
+
}
|
|
178
|
+
return tanstackMetadata(chunk)?.finishReason === 'tool_calls'
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
export interface NormalizedQueueConfig {
|
|
168
182
|
whenBusy: WhenBusy
|
|
169
183
|
drain: 'fifo' | 'batch'
|
|
@@ -416,6 +430,12 @@ export class ChatClient<
|
|
|
416
430
|
private continuationPending = false
|
|
417
431
|
private subscriptionAbortController: AbortController | null = null
|
|
418
432
|
private processingResolve: (() => void) | null = null
|
|
433
|
+
/**
|
|
434
|
+
* `connect()` adapters push the full HTTP body into the subscribe queue, then
|
|
435
|
+
* wait until that queue is idle. After `send()` returns, every chunk from this
|
|
436
|
+
* request has been processed. Subscribe/send sockets do not drain that way.
|
|
437
|
+
*/
|
|
438
|
+
private connectionDrainsOnSend = false
|
|
419
439
|
private errorReportedGeneration: number | null = null
|
|
420
440
|
private streamGeneration = 0
|
|
421
441
|
private continuationGeneration = 0
|
|
@@ -518,7 +538,9 @@ export class ChatClient<
|
|
|
518
538
|
this.byokProvider = options.byokProvider
|
|
519
539
|
this.context = options.context
|
|
520
540
|
this.queueConfig = normalizeQueueOption(options.queue)
|
|
521
|
-
|
|
541
|
+
const transport = resolveTransport(options)
|
|
542
|
+
this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
|
|
543
|
+
this.connection = normalizeConnectionAdapter(transport)
|
|
522
544
|
|
|
523
545
|
// Build client tools map
|
|
524
546
|
this.clientToolsRef = { current: new Map() }
|
|
@@ -829,6 +851,9 @@ export class ChatClient<
|
|
|
829
851
|
if (eventType === 'memory:state') {
|
|
830
852
|
this.devtoolsBridge.recordMemoryState(data)
|
|
831
853
|
}
|
|
854
|
+
if (eventType === 'skills:state') {
|
|
855
|
+
this.devtoolsBridge.recordSkillsState(data)
|
|
856
|
+
}
|
|
832
857
|
this.callbacksRef.current.onCustomEvent(eventType, data, context)
|
|
833
858
|
},
|
|
834
859
|
},
|
|
@@ -1140,7 +1165,9 @@ export class ChatClient<
|
|
|
1140
1165
|
this.clearedStreamTracker.onSessionRunError()
|
|
1141
1166
|
}
|
|
1142
1167
|
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
1143
|
-
|
|
1168
|
+
const skipProcessingResolve =
|
|
1169
|
+
chunk.type === 'RUN_FINISHED' && isIntermediateToolTurn(chunk)
|
|
1170
|
+
if (options?.resolveProcessing !== false && !skipProcessingResolve) {
|
|
1144
1171
|
this.resolveProcessing()
|
|
1145
1172
|
}
|
|
1146
1173
|
}
|
|
@@ -2344,6 +2371,14 @@ export class ChatClient<
|
|
|
2344
2371
|
return false
|
|
2345
2372
|
}
|
|
2346
2373
|
|
|
2374
|
+
// connect() send() already waited until the subscribe queue was idle.
|
|
2375
|
+
// Kick the processing wait so a stream that ends on tool_calls (no
|
|
2376
|
+
// interrupt / stop) cannot hang. Subscribe/send sockets still wait for
|
|
2377
|
+
// a request-ending terminal below.
|
|
2378
|
+
if (this.connectionDrainsOnSend) {
|
|
2379
|
+
this.resolveProcessing()
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2347
2382
|
// Wait for subscription loop to finish processing all chunks
|
|
2348
2383
|
await processingComplete
|
|
2349
2384
|
|
|
@@ -3045,12 +3080,12 @@ export class ChatClient<
|
|
|
3045
3080
|
this.resetSessionGenerating()
|
|
3046
3081
|
this.setIsSubscribed(false)
|
|
3047
3082
|
this.setConnectionStatus('disconnected')
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
)
|
|
3083
|
+
const transport = resolveTransport({
|
|
3084
|
+
connection: options.connection,
|
|
3085
|
+
fetcher: options.fetcher,
|
|
3086
|
+
})
|
|
3087
|
+
this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
|
|
3088
|
+
this.connection = normalizeConnectionAdapter(transport)
|
|
3054
3089
|
|
|
3055
3090
|
if (wasSubscribed) {
|
|
3056
3091
|
this.subscribe()
|
|
@@ -1055,6 +1055,28 @@ export function normalizeConnectionAdapter(
|
|
|
1055
1055
|
}
|
|
1056
1056
|
}
|
|
1057
1057
|
|
|
1058
|
+
async function waitUntilSubscriberIdle(
|
|
1059
|
+
abortSignal?: AbortSignal,
|
|
1060
|
+
): Promise<void> {
|
|
1061
|
+
// Idle means the subscriber is waiting for the next chunk, so the
|
|
1062
|
+
// previous chunk has left processIncomingChunk. Empty waiters with an
|
|
1063
|
+
// empty buffer is in-flight delivery, not idle.
|
|
1064
|
+
const idle = () =>
|
|
1065
|
+
activeBuffer.length === 0 &&
|
|
1066
|
+
(activeWaiters.length > 0 || abortSignal?.aborted)
|
|
1067
|
+
for (let i = 0; i < 16 && !abortSignal?.aborted; i++) {
|
|
1068
|
+
if (idle()) return
|
|
1069
|
+
await Promise.resolve()
|
|
1070
|
+
}
|
|
1071
|
+
let macrotaskWaits = 0
|
|
1072
|
+
while (!abortSignal?.aborted) {
|
|
1073
|
+
if (idle()) return
|
|
1074
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
1075
|
+
macrotaskWaits++
|
|
1076
|
+
if (activeWaiters.length === 0 && macrotaskWaits >= 32) return
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1058
1080
|
return {
|
|
1059
1081
|
subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
|
|
1060
1082
|
// Transfer ownership to the latest subscriber so only one active
|
|
@@ -1162,6 +1184,7 @@ export function normalizeConnectionAdapter(
|
|
|
1162
1184
|
}
|
|
1163
1185
|
throw err
|
|
1164
1186
|
}
|
|
1187
|
+
await waitUntilSubscriberIdle(abortSignal)
|
|
1165
1188
|
},
|
|
1166
1189
|
// Expose joinRun only when the underlying connection is resumable. Require
|
|
1167
1190
|
// a real function — `'joinRun' in connection` is true for
|
package/src/devtools-noop.ts
CHANGED
|
@@ -87,6 +87,7 @@ export class NoOpChatDevtoolsBridge {
|
|
|
87
87
|
}
|
|
88
88
|
observeChunk(_chunk: StreamChunk): void {}
|
|
89
89
|
recordMemoryState(_value: unknown): void {}
|
|
90
|
+
recordSkillsState(_value: unknown): void {}
|
|
90
91
|
beginRun(_runId: string, _threadId: string): void {}
|
|
91
92
|
getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
|
|
92
93
|
return undefined
|
package/src/devtools.ts
CHANGED
|
@@ -33,6 +33,11 @@ export interface AIDevtoolsDisplayOptions {
|
|
|
33
33
|
* payload of the `memory:state` CUSTOM chunk. Kept local so `ai-client` doesn't
|
|
34
34
|
* depend on `ai-memory`; the memory middleware is the producer.
|
|
35
35
|
*/
|
|
36
|
+
interface SkillsStateEventValue {
|
|
37
|
+
catalog?: Array<{ name: string; description: string }>
|
|
38
|
+
activated?: Array<string>
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
interface MemoryStateEventValue {
|
|
37
42
|
scope: MemoryScopeLite
|
|
38
43
|
adapter: string
|
|
@@ -448,6 +453,49 @@ function getActiveBridgeRegistry(): Map<string, ActiveDevtoolsBridge> {
|
|
|
448
453
|
return registry
|
|
449
454
|
}
|
|
450
455
|
|
|
456
|
+
/**
|
|
457
|
+
* `{...options}` turns `get hookId()` into a data property. Chat/generation
|
|
458
|
+
* clients mint `threadId` after construct (`ensureThreadId` on mount), so a
|
|
459
|
+
* spread would freeze `hookId: ''` and DevTools could never select the hook.
|
|
460
|
+
*/
|
|
461
|
+
function withLiveClientIdentity<TSnapshot extends object>(
|
|
462
|
+
identity: Pick<
|
|
463
|
+
AIDevtoolsBridgeOptions<TSnapshot>,
|
|
464
|
+
| 'hookId'
|
|
465
|
+
| 'clientId'
|
|
466
|
+
| 'threadId'
|
|
467
|
+
| 'metadata'
|
|
468
|
+
| 'getTools'
|
|
469
|
+
| 'applyToolFixture'
|
|
470
|
+
>,
|
|
471
|
+
rest: Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getSnapshot'> &
|
|
472
|
+
Partial<
|
|
473
|
+
Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getTools' | 'applyToolFixture'>
|
|
474
|
+
>,
|
|
475
|
+
): AIDevtoolsBridgeOptions<TSnapshot> {
|
|
476
|
+
return {
|
|
477
|
+
get hookId() {
|
|
478
|
+
return identity.hookId
|
|
479
|
+
},
|
|
480
|
+
get clientId() {
|
|
481
|
+
return identity.clientId
|
|
482
|
+
},
|
|
483
|
+
get threadId() {
|
|
484
|
+
return identity.threadId
|
|
485
|
+
},
|
|
486
|
+
metadata: identity.metadata,
|
|
487
|
+
getSnapshot: rest.getSnapshot,
|
|
488
|
+
...(rest.getTools || identity.getTools
|
|
489
|
+
? { getTools: rest.getTools ?? identity.getTools }
|
|
490
|
+
: {}),
|
|
491
|
+
...(rest.applyToolFixture || identity.applyToolFixture
|
|
492
|
+
? {
|
|
493
|
+
applyToolFixture: rest.applyToolFixture ?? identity.applyToolFixture,
|
|
494
|
+
}
|
|
495
|
+
: {}),
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
451
499
|
export class ClientDevtoolsBridge<TSnapshot extends object> {
|
|
452
500
|
protected readonly options: AIDevtoolsBridgeOptions<TSnapshot>
|
|
453
501
|
private readonly unsubscribers: Array<Unsubscribe> = []
|
|
@@ -712,6 +760,7 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
|
|
|
712
760
|
| 'memory:retrieve:started'
|
|
713
761
|
| 'memory:retrieve:completed'
|
|
714
762
|
| 'memory:snapshot'
|
|
763
|
+
| 'skills:snapshot'
|
|
715
764
|
| AIDevtoolsRunEventType,
|
|
716
765
|
visibility: AIDevtoolsEventVisibility = 'client-state',
|
|
717
766
|
context: { runId?: string } = {},
|
|
@@ -770,17 +819,24 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
|
|
|
770
819
|
private lastRunEventContext: ChatClientRunEventContext | undefined
|
|
771
820
|
/** Last transported `memory:state` value, replayed when a panel opens. */
|
|
772
821
|
private lastMemoryStateValue: unknown = null
|
|
822
|
+
/** Last transported `skills:state` value, replayed when a panel opens. */
|
|
823
|
+
private lastSkillsStateValue: unknown = null
|
|
773
824
|
|
|
774
825
|
constructor(options: ChatDevtoolsBridgeOptions) {
|
|
775
|
-
super(
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
826
|
+
super(
|
|
827
|
+
withLiveClientIdentity(options, {
|
|
828
|
+
getSnapshot: options.getSnapshot,
|
|
829
|
+
// Thunk defers `this.applyFixture` lookup until after `super` returns.
|
|
830
|
+
applyToolFixture: (fixture) => this.applyFixture(fixture),
|
|
831
|
+
}),
|
|
832
|
+
)
|
|
780
833
|
this.chatOptions = options
|
|
781
834
|
// Auto-attaches run/thread context and auto-emits a snapshot after each
|
|
782
835
|
// event so callers can keep using `this.events.X(...)` with no context arg.
|
|
783
|
-
this.events = new ChatDevtoolsAwareEventEmitter(
|
|
836
|
+
this.events = new ChatDevtoolsAwareEventEmitter(
|
|
837
|
+
() => options.clientId,
|
|
838
|
+
this,
|
|
839
|
+
)
|
|
784
840
|
}
|
|
785
841
|
|
|
786
842
|
// --- Stream / run context API -------------------------------------------
|
|
@@ -874,6 +930,11 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
|
|
|
874
930
|
this.emitMemoryState(rawValue)
|
|
875
931
|
}
|
|
876
932
|
|
|
933
|
+
recordSkillsState(rawValue: unknown): void {
|
|
934
|
+
this.lastSkillsStateValue = rawValue
|
|
935
|
+
this.emitSkillsState(rawValue)
|
|
936
|
+
}
|
|
937
|
+
|
|
877
938
|
/**
|
|
878
939
|
* Re-emit the browser-side `memory:*` events from a transported
|
|
879
940
|
* `memory:state` value. The devtools store consumes these to render the
|
|
@@ -926,10 +987,26 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
|
|
|
926
987
|
}
|
|
927
988
|
}
|
|
928
989
|
|
|
990
|
+
private emitSkillsState(rawValue: unknown): void {
|
|
991
|
+
if (!rawValue || typeof rawValue !== 'object') return
|
|
992
|
+
const value = rawValue as SkillsStateEventValue
|
|
993
|
+
const catalog = Array.isArray(value.catalog) ? value.catalog : []
|
|
994
|
+
const activated = Array.isArray(value.activated) ? value.activated : []
|
|
995
|
+
const runContext = this.currentRunId ? { runId: this.currentRunId } : {}
|
|
996
|
+
emitAIDevtoolsEvent('skills:snapshot', {
|
|
997
|
+
...this.createEnvelope('skills:snapshot', 'client-state', runContext),
|
|
998
|
+
catalog,
|
|
999
|
+
activated,
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
|
|
929
1003
|
protected override onReplayState(): void {
|
|
930
1004
|
if (this.lastMemoryStateValue != null) {
|
|
931
1005
|
this.emitMemoryState(this.lastMemoryStateValue)
|
|
932
1006
|
}
|
|
1007
|
+
if (this.lastSkillsStateValue != null) {
|
|
1008
|
+
this.emitSkillsState(this.lastSkillsStateValue)
|
|
1009
|
+
}
|
|
933
1010
|
}
|
|
934
1011
|
|
|
935
1012
|
getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
|
|
@@ -1410,10 +1487,11 @@ export class GenerationDevtoolsBridge<TOutput> extends ClientDevtoolsBridge<
|
|
|
1410
1487
|
protected readonly getCoreState: () => GenerationDevtoolsCoreState<TOutput>
|
|
1411
1488
|
|
|
1412
1489
|
constructor(options: GenerationDevtoolsBridgeOptions<TOutput>) {
|
|
1413
|
-
super(
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1490
|
+
super(
|
|
1491
|
+
withLiveClientIdentity(options, {
|
|
1492
|
+
getSnapshot: () => this.buildSnapshot(),
|
|
1493
|
+
}),
|
|
1494
|
+
)
|
|
1417
1495
|
this.maxRuns = options.maxRuns ?? 20
|
|
1418
1496
|
this.getCoreState = options.getCoreState
|
|
1419
1497
|
}
|
|
@@ -1759,10 +1837,18 @@ export class VideoDevtoolsBridge<
|
|
|
1759
1837
|
// so resolveStreamId() works without the chat client telling it.
|
|
1760
1838
|
class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter {
|
|
1761
1839
|
constructor(
|
|
1762
|
-
|
|
1840
|
+
private readonly getClientId: () => string,
|
|
1763
1841
|
private readonly helper: ChatDevtoolsBridge,
|
|
1764
1842
|
) {
|
|
1765
|
-
super(
|
|
1843
|
+
super(getClientId())
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
protected override emitEvent(
|
|
1847
|
+
eventName: string,
|
|
1848
|
+
data?: Record<string, any>,
|
|
1849
|
+
): void {
|
|
1850
|
+
this.clientId = this.getClientId()
|
|
1851
|
+
super.emitEvent(eventName, data)
|
|
1766
1852
|
}
|
|
1767
1853
|
|
|
1768
1854
|
private afterEmit(streamId?: string): void {
|