@tanstack/ai-client 0.22.1 → 0.23.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.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/audio-recorder.js +190 -213
  3. package/dist/esm/audio-recorder.js.map +1 -1
  4. package/dist/esm/chat-client.d.ts +172 -3
  5. package/dist/esm/chat-client.js +1656 -1386
  6. package/dist/esm/chat-client.js.map +1 -1
  7. package/dist/esm/cleared-stream-tracker.d.ts +23 -0
  8. package/dist/esm/cleared-stream-tracker.js +97 -0
  9. package/dist/esm/cleared-stream-tracker.js.map +1 -0
  10. package/dist/esm/client-persistor.d.ts +25 -12
  11. package/dist/esm/client-persistor.js +260 -235
  12. package/dist/esm/client-persistor.js.map +1 -1
  13. package/dist/esm/connection-adapters.d.ts +231 -10
  14. package/dist/esm/connection-adapters.js +989 -574
  15. package/dist/esm/connection-adapters.js.map +1 -1
  16. package/dist/esm/devtools-noop.d.ts +1 -0
  17. package/dist/esm/devtools-noop.js +79 -139
  18. package/dist/esm/devtools-noop.js.map +1 -1
  19. package/dist/esm/devtools.d.ts +31 -1
  20. package/dist/esm/devtools.js +977 -1127
  21. package/dist/esm/devtools.js.map +1 -1
  22. package/dist/esm/events.js +224 -226
  23. package/dist/esm/events.js.map +1 -1
  24. package/dist/esm/generation-client.d.ts +145 -2
  25. package/dist/esm/generation-client.js +659 -321
  26. package/dist/esm/generation-client.js.map +1 -1
  27. package/dist/esm/generation-reconstruct.d.ts +21 -0
  28. package/dist/esm/generation-reconstruct.js +85 -0
  29. package/dist/esm/generation-reconstruct.js.map +1 -0
  30. package/dist/esm/generation-types.d.ts +289 -3
  31. package/dist/esm/generation-types.js +356 -13
  32. package/dist/esm/generation-types.js.map +1 -1
  33. package/dist/esm/index.d.ts +9 -4
  34. package/dist/esm/index.js +7 -39
  35. package/dist/esm/interrupt-manager.d.ts +77 -0
  36. package/dist/esm/interrupt-manager.js +787 -0
  37. package/dist/esm/interrupt-manager.js.map +1 -0
  38. package/dist/esm/mcp-app-bridge.js +56 -64
  39. package/dist/esm/mcp-app-bridge.js.map +1 -1
  40. package/dist/esm/realtime-client.js +366 -440
  41. package/dist/esm/realtime-client.js.map +1 -1
  42. package/dist/esm/response-stream.js +19 -26
  43. package/dist/esm/response-stream.js.map +1 -1
  44. package/dist/esm/sse-parser.js +44 -47
  45. package/dist/esm/sse-parser.js.map +1 -1
  46. package/dist/esm/sse-utils.js +8 -9
  47. package/dist/esm/sse-utils.js.map +1 -1
  48. package/dist/esm/storage-adapters.d.ts +62 -0
  49. package/dist/esm/storage-adapters.js +174 -0
  50. package/dist/esm/storage-adapters.js.map +1 -0
  51. package/dist/esm/types.d.ts +212 -10
  52. package/dist/esm/types.js +38 -7
  53. package/dist/esm/types.js.map +1 -1
  54. package/dist/esm/video-generation-client.d.ts +113 -2
  55. package/dist/esm/video-generation-client.js +665 -379
  56. package/dist/esm/video-generation-client.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/chat-client.ts +1079 -61
  59. package/src/cleared-stream-tracker.ts +151 -0
  60. package/src/client-persistor.ts +102 -33
  61. package/src/connection-adapters.ts +1185 -142
  62. package/src/devtools-noop.ts +4 -3
  63. package/src/devtools.ts +121 -3
  64. package/src/generation-client.ts +563 -13
  65. package/src/generation-reconstruct.ts +121 -0
  66. package/src/generation-types.ts +727 -3
  67. package/src/index.ts +56 -1
  68. package/src/interrupt-manager.ts +1440 -0
  69. package/src/storage-adapters.ts +242 -0
  70. package/src/types.ts +301 -9
  71. package/src/video-generation-client.ts +479 -13
  72. package/dist/esm/index.js.map +0 -1
@@ -0,0 +1,151 @@
1
+ import { getChunkRunId } from './connection-adapters'
2
+ import type { StreamChunk } from '@tanstack/ai/client'
3
+ import type { UIMessage } from './types'
4
+
5
+ function getChunkToolCallId(chunk: StreamChunk): string | undefined {
6
+ return 'toolCallId' in chunk && typeof chunk.toolCallId === 'string'
7
+ ? chunk.toolCallId
8
+ : undefined
9
+ }
10
+
11
+ function getChunkMessageId(chunk: StreamChunk): string | undefined {
12
+ return 'messageId' in chunk && typeof chunk.messageId === 'string'
13
+ ? chunk.messageId
14
+ : undefined
15
+ }
16
+
17
+ function getChunkParentMessageId(chunk: StreamChunk): string | undefined {
18
+ return 'parentMessageId' in chunk && typeof chunk.parentMessageId === 'string'
19
+ ? chunk.parentMessageId
20
+ : undefined
21
+ }
22
+
23
+ /** Tracks stream chunks that must be ignored after the owning chat is cleared. */
24
+ export class ClearedStreamTracker {
25
+ private readonly clearedMessageIds = new Set<string>()
26
+ private readonly clearedRunIds = new Set<string>()
27
+ private readonly ignoredActiveRunIds = new Set<string>()
28
+ private readonly clearedToolCallIds = new Set<string>()
29
+ private currentRunlessRunId: string | null = null
30
+
31
+ snapshotClear(context: {
32
+ messages: Array<UIMessage>
33
+ activeRunIds: Set<string>
34
+ currentRunId: string | null
35
+ }): void {
36
+ for (const message of context.messages) {
37
+ this.clearedMessageIds.add(message.id)
38
+ }
39
+ for (const runId of context.activeRunIds) {
40
+ this.clearedRunIds.add(runId)
41
+ this.ignoredActiveRunIds.add(runId)
42
+ }
43
+ if (context.currentRunId) {
44
+ this.clearedRunIds.add(context.currentRunId)
45
+ this.ignoredActiveRunIds.add(context.currentRunId)
46
+ }
47
+ }
48
+
49
+ shouldIgnoreChunk(chunk: StreamChunk): boolean {
50
+ const runId = getChunkRunId(chunk)
51
+ if (runId && this.clearedRunIds.has(runId)) {
52
+ if (chunk.type === 'RUN_STARTED') {
53
+ this.ignoredActiveRunIds.add(runId)
54
+ this.currentRunlessRunId = runId
55
+ }
56
+ this.markIgnoredChunkIds(chunk)
57
+ return true
58
+ }
59
+
60
+ if (runId && this.ignoredActiveRunIds.has(runId)) {
61
+ this.markIgnoredChunkIds(chunk)
62
+ return true
63
+ }
64
+
65
+ if (this.isRunlessChunkFromIgnoredRun(chunk)) {
66
+ this.markIgnoredChunkIds(chunk)
67
+ return true
68
+ }
69
+
70
+ const toolCallId = getChunkToolCallId(chunk)
71
+ if (toolCallId && this.clearedToolCallIds.has(toolCallId)) {
72
+ return true
73
+ }
74
+
75
+ const parentMessageId = getChunkParentMessageId(chunk)
76
+ if (parentMessageId && this.clearedMessageIds.has(parentMessageId)) {
77
+ if (toolCallId) {
78
+ this.clearedToolCallIds.add(toolCallId)
79
+ }
80
+ return true
81
+ }
82
+
83
+ const messageId = getChunkMessageId(chunk)
84
+ return Boolean(messageId && this.clearedMessageIds.has(messageId))
85
+ }
86
+
87
+ onRunStarted(runId: string): void {
88
+ this.currentRunlessRunId = runId
89
+ }
90
+
91
+ onRunSettled(runId: string): void {
92
+ this.ignoredActiveRunIds.delete(runId)
93
+ this.clearedRunIds.delete(runId)
94
+ if (this.currentRunlessRunId === runId) {
95
+ this.currentRunlessRunId =
96
+ this.ignoredActiveRunIds.values().next().value ?? null
97
+ }
98
+ }
99
+
100
+ onSessionRunError(): void {
101
+ this.ignoredActiveRunIds.clear()
102
+ this.currentRunlessRunId = null
103
+ }
104
+
105
+ resetActiveRuns(): void {
106
+ this.ignoredActiveRunIds.clear()
107
+ this.currentRunlessRunId = null
108
+ }
109
+
110
+ takeRunlessRunId(): string | null {
111
+ const runId = this.currentRunlessRunId
112
+ if (!runId) return null
113
+ this.ignoredActiveRunIds.delete(runId)
114
+ this.clearedRunIds.delete(runId)
115
+ this.currentRunlessRunId =
116
+ this.ignoredActiveRunIds.values().next().value ?? null
117
+ return runId
118
+ }
119
+
120
+ private markIgnoredChunkIds(chunk: StreamChunk): void {
121
+ const messageId = getChunkMessageId(chunk)
122
+ if (messageId) {
123
+ this.clearedMessageIds.add(messageId)
124
+ }
125
+ const toolCallId = getChunkToolCallId(chunk)
126
+ if (toolCallId) {
127
+ this.clearedToolCallIds.add(toolCallId)
128
+ }
129
+ }
130
+
131
+ private isRunlessChunkFromIgnoredRun(chunk: StreamChunk): boolean {
132
+ const runId = getChunkRunId(chunk)
133
+ if (runId || !this.currentRunlessRunId) return false
134
+ if (
135
+ !this.ignoredActiveRunIds.has(this.currentRunlessRunId) &&
136
+ !this.clearedRunIds.has(this.currentRunlessRunId)
137
+ ) {
138
+ return false
139
+ }
140
+ return (
141
+ chunk.type === 'TEXT_MESSAGE_START' ||
142
+ chunk.type === 'TEXT_MESSAGE_CONTENT' ||
143
+ chunk.type === 'TOOL_CALL_START' ||
144
+ chunk.type === 'TOOL_CALL_ARGS' ||
145
+ chunk.type === 'TOOL_CALL_END' ||
146
+ chunk.type === 'TOOL_CALL_RESULT' ||
147
+ chunk.type === 'MESSAGES_SNAPSHOT' ||
148
+ chunk.type === 'RUN_ERROR'
149
+ )
150
+ }
151
+ }
@@ -1,6 +1,20 @@
1
1
  import { getChunkRunId } from './connection-adapters'
2
2
  import type { StreamChunk } from '@tanstack/ai/client'
3
- import type { ChatClientPersistence, UIMessage } from './types'
3
+ import type {
4
+ ChatClientPersistence,
5
+ ChatPersistedState,
6
+ ChatResumeSnapshot,
7
+ UIMessage,
8
+ } from './types'
9
+
10
+ /** Normalize a raw `getItem` result (legacy bare array or combined record). */
11
+ function normalizePersistedState(
12
+ raw: ChatPersistedState | Array<UIMessage> | null | undefined,
13
+ ): ChatPersistedState | undefined {
14
+ if (Array.isArray(raw)) return { messages: raw }
15
+ if (raw && Array.isArray(raw.messages)) return raw
16
+ return undefined
17
+ }
4
18
 
5
19
  // `StreamChunk` is a discriminated union; `toolCallId` / `messageId` /
6
20
  // `parentMessageId` exist on only some members. Narrow with `in` (matching
@@ -30,10 +44,12 @@ function getChunkParentMessageId(chunk: StreamChunk): string | undefined {
30
44
  *
31
45
  * Two responsibilities live here:
32
46
  *
33
- * 1. **Storage orchestration** — hydrate from `getItem(id)` on creation, save to
34
- * `setItem(id, messages)` on every change through an ordered write queue, and
35
- * `removeItem(id)` on clear. A generation counter discards stale writes when a
36
- * removal or a newer conversation supersedes an in-flight async operation.
47
+ * 1. **Storage orchestration** — hydrate from `getItem(id)` on creation, save a
48
+ * combined `{ messages, resume? }` record via `setItem` on every change
49
+ * through an ordered write queue, and `removeItem(id)` on clear (or when
50
+ * both the transcript and resume pointer are empty). A generation counter
51
+ * discards stale writes when a removal or a newer conversation supersedes
52
+ * an in-flight async operation.
37
53
  * 2. **Clear-during-stream suppression** — when a conversation is cleared while a
38
54
  * stream is still producing, late chunks for the cleared run(s) must not
39
55
  * repopulate the now-empty state. The persistor tracks the cleared ids and
@@ -51,6 +67,10 @@ export class ChatPersistor {
51
67
  // Bumped on every message change; lets an in-flight async hydration detect
52
68
  // that the message list moved on and avoid clobbering it.
53
69
  private messagesGeneration = 0
70
+ // Latest messages + resume snapshot, written together as one combined record
71
+ // so a full page reload restores both from a single adapter key.
72
+ private lastMessages: Array<UIMessage> = []
73
+ private lastResume: ChatResumeSnapshot | null = null
54
74
 
55
75
  // --- clear-during-stream suppression state ---
56
76
  private readonly clearedMessageIds = new Set<string>()
@@ -63,51 +83,91 @@ export class ChatPersistor {
63
83
  private readonly adapter: ChatClientPersistence,
64
84
  private readonly id: string,
65
85
  private readonly applyMessages: (messages: Array<UIMessage>) => void,
86
+ private readonly applyResume?: (snapshot: ChatResumeSnapshot) => void,
66
87
  ) {}
67
88
 
89
+ /** Persist the current state as one combined `{ messages, resume? }` record. */
90
+ private writeState(): void {
91
+ const messages = [...this.lastMessages]
92
+ // Nothing to persist (no transcript, no resume pointer): remove the key
93
+ // rather than writing an empty `{ messages: [] }`, so a cleared
94
+ // conversation does not leave a stale record behind.
95
+ if (messages.length === 0 && !this.lastResume) {
96
+ const generation = this.generation
97
+ this.runOperation(() => {
98
+ if (generation !== this.generation) {
99
+ return
100
+ }
101
+ return this.adapter.removeItem(this.id)
102
+ })
103
+ return
104
+ }
105
+ const generation = this.generation
106
+ const state: ChatPersistedState = {
107
+ messages,
108
+ ...(this.lastResume ? { resume: this.lastResume } : {}),
109
+ }
110
+ this.runOperation(() => {
111
+ if (generation !== this.generation) {
112
+ return
113
+ }
114
+ return this.adapter.setItem(this.id, state)
115
+ })
116
+ }
117
+
68
118
  // ---------------------------------------------------------------------------
69
119
  // Storage orchestration
70
120
  // ---------------------------------------------------------------------------
71
121
 
72
122
  /**
73
- * Synchronously read the persisted messages for constructor-time hydration.
74
- * Returns the raw `getItem` result (which may be a promise for async stores).
123
+ * Synchronously read the persisted state for constructor-time hydration.
124
+ * Returns the normalized combined record, or a promise of it for async stores.
75
125
  */
76
126
  readInitial():
77
- | Array<UIMessage>
78
- | null
127
+ | ChatPersistedState
79
128
  | undefined
80
- | Promise<Array<UIMessage> | null | undefined> {
129
+ | Promise<ChatPersistedState | undefined> {
81
130
  try {
82
- return this.adapter.getItem(this.id)
131
+ const raw = this.adapter.getItem(this.id)
132
+ if (raw instanceof Promise) {
133
+ return raw.then(normalizePersistedState).catch(() => undefined)
134
+ }
135
+ const state = normalizePersistedState(raw)
136
+ if (state) {
137
+ this.lastMessages = state.messages
138
+ this.lastResume = state.resume ?? null
139
+ }
140
+ return state
83
141
  } catch {
84
142
  return undefined
85
143
  }
86
144
  }
87
145
 
88
146
  /**
89
- * Apply messages from an async `getItem` once it resolves, unless the message
147
+ * Apply state from an async `getItem` once it resolves, unless the message
90
148
  * list has already changed since hydration began.
91
149
  */
92
150
  hydrateAsync(
93
- persistedMessages:
94
- | Array<UIMessage>
95
- | null
151
+ persistedState:
152
+ | ChatPersistedState
96
153
  | undefined
97
- | Promise<Array<UIMessage> | null | undefined>,
154
+ | Promise<ChatPersistedState | undefined>,
98
155
  ): void {
99
- if (!(persistedMessages instanceof Promise)) {
156
+ if (!(persistedState instanceof Promise)) {
100
157
  return
101
158
  }
102
159
 
103
160
  const hydrationGeneration = this.messagesGeneration
104
- persistedMessages
105
- .then((messages) => {
106
- if (
107
- Array.isArray(messages) &&
108
- this.messagesGeneration === hydrationGeneration
109
- ) {
110
- this.applyMessages(messages)
161
+ persistedState
162
+ .then((state) => {
163
+ if (!state || this.messagesGeneration !== hydrationGeneration) {
164
+ return
165
+ }
166
+ this.lastResume = state.resume ?? null
167
+ this.lastMessages = state.messages
168
+ this.applyMessages(state.messages)
169
+ if (state.resume && this.applyResume) {
170
+ this.applyResume(state.resume)
111
171
  }
112
172
  })
113
173
  .catch(() => {
@@ -116,28 +176,37 @@ export class ChatPersistor {
116
176
  }
117
177
 
118
178
  /**
119
- * Record a message-list change and queue a `setItem` write for it. Skips a
179
+ * Record a message-list change and queue a combined write for it. Skips a
120
180
  * single write after {@link beginClear} so the clear's empty snapshot isn't
121
181
  * persisted between `clearMessages()` and {@link remove}.
122
182
  */
123
183
  notifyMessagesChanged(messages: Array<UIMessage>): void {
124
184
  this.messagesGeneration++
185
+ this.lastMessages = [...messages]
125
186
  if (this.skipNextPersist) {
126
187
  this.skipNextPersist = false
127
188
  return
128
189
  }
129
- const generation = this.generation
130
- const messagesSnapshot = [...messages]
131
- this.runOperation(() => {
132
- if (generation !== this.generation) {
133
- return
134
- }
135
- return this.adapter.setItem(this.id, messagesSnapshot)
136
- })
190
+ this.writeState()
191
+ }
192
+
193
+ /**
194
+ * Record the current resume snapshot (which run to rejoin / which interrupts
195
+ * are pending) and persist it alongside the messages. Pass `null` to clear it
196
+ * once the run reaches a non-interrupt terminal.
197
+ */
198
+ persistResumeSnapshot(snapshot: ChatResumeSnapshot | null): void {
199
+ this.lastResume = snapshot
200
+ if (this.skipNextPersist) {
201
+ return
202
+ }
203
+ this.writeState()
137
204
  }
138
205
 
139
206
  /** Remove the persisted conversation. Invalidates any queued writes. */
140
207
  remove(): void {
208
+ this.lastMessages = []
209
+ this.lastResume = null
141
210
  const generation = ++this.generation
142
211
  this.runOperation(() => {
143
212
  if (generation !== this.generation) {