@lota-sdk/ui 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/ui",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -25,7 +25,7 @@
25
25
  "registry": "https://registry.npmjs.org/"
26
26
  },
27
27
  "dependencies": {
28
- "@lota-sdk/shared": "0.3.1",
28
+ "@lota-sdk/shared": "0.3.2",
29
29
  "ai": "^6.0.145"
30
30
  },
31
31
  "peerDependencies": {
@@ -0,0 +1,249 @@
1
+ import type { UseChatHelpers } from '@ai-sdk/react'
2
+ import { AbstractChat } from 'ai'
3
+ import type { ChatInit, ChatState, ChatStatus, UIMessage } from 'ai'
4
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
5
+
6
+ function throttleCallback(callback: () => void, waitMs?: number): () => void {
7
+ if (waitMs == null) {
8
+ return callback
9
+ }
10
+
11
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
12
+ let hasPendingCall = false
13
+
14
+ return () => {
15
+ if (timeoutId !== null) {
16
+ hasPendingCall = true
17
+ return
18
+ }
19
+
20
+ callback()
21
+ timeoutId = setTimeout(() => {
22
+ timeoutId = null
23
+
24
+ if (!hasPendingCall) {
25
+ return
26
+ }
27
+
28
+ hasPendingCall = false
29
+ callback()
30
+ }, waitMs)
31
+ }
32
+ }
33
+
34
+ class SnapshottingChatState<UI_MESSAGE extends UIMessage> implements ChatState<UI_MESSAGE> {
35
+ #messages: UI_MESSAGE[]
36
+ #status: ChatStatus = 'ready'
37
+ #error: Error | undefined = undefined
38
+
39
+ #messagesCallbacks = new Set<() => void>()
40
+ #statusCallbacks = new Set<() => void>()
41
+ #errorCallbacks = new Set<() => void>()
42
+
43
+ constructor(initialMessages: UI_MESSAGE[] = []) {
44
+ this.#messages = initialMessages
45
+ }
46
+
47
+ get status(): ChatStatus {
48
+ return this.#status
49
+ }
50
+
51
+ set status(newStatus: ChatStatus) {
52
+ this.#status = newStatus
53
+ this.#callStatusCallbacks()
54
+ }
55
+
56
+ get error(): Error | undefined {
57
+ return this.#error
58
+ }
59
+
60
+ set error(newError: Error | undefined) {
61
+ this.#error = newError
62
+ this.#callErrorCallbacks()
63
+ }
64
+
65
+ get messages(): UI_MESSAGE[] {
66
+ return this.#messages
67
+ }
68
+
69
+ set messages(newMessages: UI_MESSAGE[]) {
70
+ this.#messages = [...newMessages]
71
+ this.#callMessagesCallbacks()
72
+ }
73
+
74
+ pushMessage = (message: UI_MESSAGE) => {
75
+ const lastMessage = this.#messages.at(-1)
76
+
77
+ if (lastMessage?.role === 'assistant' && message.role === 'assistant' && lastMessage.id !== message.id) {
78
+ // The AI SDK reuses one mutable assistant message object across consecutive `start` events
79
+ // in a single stream. When a second assistant starts, clear inherited parts so the new
80
+ // message begins empty instead of briefly rendering the previous assistant's content.
81
+ message.parts = [] as UI_MESSAGE['parts']
82
+ }
83
+
84
+ // Snapshot on insert so later stream mutations cannot rewrite already-rendered messages.
85
+ this.#messages = this.#messages.concat(this.snapshot(message))
86
+ this.#callMessagesCallbacks()
87
+ }
88
+
89
+ popMessage = () => {
90
+ this.#messages = this.#messages.slice(0, -1)
91
+ this.#callMessagesCallbacks()
92
+ }
93
+
94
+ replaceMessage = (index: number, message: UI_MESSAGE) => {
95
+ this.#messages = [...this.#messages.slice(0, index), this.snapshot(message), ...this.#messages.slice(index + 1)]
96
+ this.#callMessagesCallbacks()
97
+ }
98
+
99
+ snapshot = <T>(value: T): T => structuredClone(value)
100
+
101
+ '~registerMessagesCallback' = (onChange: () => void, throttleWaitMs?: number): (() => void) => {
102
+ const callback = throttleCallback(onChange, throttleWaitMs)
103
+ this.#messagesCallbacks.add(callback)
104
+ return () => {
105
+ this.#messagesCallbacks.delete(callback)
106
+ }
107
+ }
108
+
109
+ '~registerStatusCallback' = (onChange: () => void): (() => void) => {
110
+ this.#statusCallbacks.add(onChange)
111
+ return () => {
112
+ this.#statusCallbacks.delete(onChange)
113
+ }
114
+ }
115
+
116
+ '~registerErrorCallback' = (onChange: () => void): (() => void) => {
117
+ this.#errorCallbacks.add(onChange)
118
+ return () => {
119
+ this.#errorCallbacks.delete(onChange)
120
+ }
121
+ }
122
+
123
+ #callMessagesCallbacks = () => {
124
+ this.#messagesCallbacks.forEach((callback) => callback())
125
+ }
126
+
127
+ #callStatusCallbacks = () => {
128
+ this.#statusCallbacks.forEach((callback) => callback())
129
+ }
130
+
131
+ #callErrorCallbacks = () => {
132
+ this.#errorCallbacks.forEach((callback) => callback())
133
+ }
134
+ }
135
+
136
+ class SnapshottingChat<UI_MESSAGE extends UIMessage> extends AbstractChat<UI_MESSAGE> {
137
+ #state: SnapshottingChatState<UI_MESSAGE>
138
+
139
+ constructor({ messages, ...init }: ChatInit<UI_MESSAGE>) {
140
+ const state = new SnapshottingChatState(messages)
141
+ super({ ...init, state })
142
+ this.#state = state
143
+ }
144
+
145
+ '~registerMessagesCallback' = (onChange: () => void, throttleWaitMs?: number): (() => void) =>
146
+ this.#state['~registerMessagesCallback'](onChange, throttleWaitMs)
147
+
148
+ '~registerStatusCallback' = (onChange: () => void): (() => void) => this.#state['~registerStatusCallback'](onChange)
149
+
150
+ '~registerErrorCallback' = (onChange: () => void): (() => void) => this.#state['~registerErrorCallback'](onChange)
151
+ }
152
+
153
+ type UseSnapshottingChatOptions<UI_MESSAGE extends UIMessage> = ChatInit<UI_MESSAGE> & {
154
+ experimental_throttle?: number
155
+ resume?: boolean
156
+ }
157
+
158
+ export function useSnapshottingChat<UI_MESSAGE extends UIMessage = UIMessage>({
159
+ experimental_throttle: throttleWaitMs,
160
+ resume = false,
161
+ ...options
162
+ }: UseSnapshottingChatOptions<UI_MESSAGE> = {}): UseChatHelpers<UI_MESSAGE> {
163
+ const callbacksRef = useRef({
164
+ onToolCall: options.onToolCall,
165
+ onData: options.onData,
166
+ onFinish: options.onFinish,
167
+ onError: options.onError,
168
+ sendAutomaticallyWhen: options.sendAutomaticallyWhen,
169
+ })
170
+
171
+ callbacksRef.current = {
172
+ onToolCall: options.onToolCall,
173
+ onData: options.onData,
174
+ onFinish: options.onFinish,
175
+ onError: options.onError,
176
+ sendAutomaticallyWhen: options.sendAutomaticallyWhen,
177
+ }
178
+
179
+ const optionsWithCallbacks: ChatInit<UI_MESSAGE> = {
180
+ ...options,
181
+ onToolCall: (arg) => callbacksRef.current.onToolCall?.(arg),
182
+ onData: (arg) => callbacksRef.current.onData?.(arg),
183
+ onFinish: (arg) => callbacksRef.current.onFinish?.(arg),
184
+ onError: (arg) => callbacksRef.current.onError?.(arg),
185
+ sendAutomaticallyWhen: (arg) => callbacksRef.current.sendAutomaticallyWhen?.(arg) ?? false,
186
+ }
187
+
188
+ const chatRef = useRef<SnapshottingChat<UI_MESSAGE>>(new SnapshottingChat(optionsWithCallbacks))
189
+
190
+ if (chatRef.current.id !== (options.id ?? chatRef.current.id)) {
191
+ chatRef.current = new SnapshottingChat(optionsWithCallbacks)
192
+ }
193
+
194
+ const subscribeToMessages = useCallback(
195
+ (update: () => void) => chatRef.current['~registerMessagesCallback'](update, throttleWaitMs),
196
+ [throttleWaitMs, chatRef.current.id],
197
+ )
198
+
199
+ const messages = useSyncExternalStore(
200
+ subscribeToMessages,
201
+ () => chatRef.current.messages,
202
+ () => chatRef.current.messages,
203
+ )
204
+
205
+ const status = useSyncExternalStore(
206
+ chatRef.current['~registerStatusCallback'],
207
+ () => chatRef.current.status,
208
+ () => chatRef.current.status,
209
+ )
210
+
211
+ const error = useSyncExternalStore(
212
+ chatRef.current['~registerErrorCallback'],
213
+ () => chatRef.current.error,
214
+ () => chatRef.current.error,
215
+ )
216
+
217
+ const setMessages = useCallback(
218
+ (messagesParam: UI_MESSAGE[] | ((messages: UI_MESSAGE[]) => UI_MESSAGE[])) => {
219
+ if (typeof messagesParam === 'function') {
220
+ messagesParam = messagesParam(chatRef.current.messages)
221
+ }
222
+
223
+ chatRef.current.messages = messagesParam
224
+ },
225
+ [],
226
+ )
227
+
228
+ useEffect(() => {
229
+ if (resume) {
230
+ chatRef.current.resumeStream()
231
+ }
232
+ }, [resume])
233
+
234
+ return {
235
+ id: chatRef.current.id,
236
+ messages,
237
+ setMessages,
238
+ sendMessage: chatRef.current.sendMessage,
239
+ regenerate: chatRef.current.regenerate,
240
+ clearError: chatRef.current.clearError,
241
+ stop: chatRef.current.stop,
242
+ error,
243
+ resumeStream: chatRef.current.resumeStream,
244
+ status,
245
+ addToolResult: chatRef.current.addToolOutput,
246
+ addToolOutput: chatRef.current.addToolOutput,
247
+ addToolApprovalResponse: chatRef.current.addToolApprovalResponse,
248
+ }
249
+ }
@@ -1,9 +1,10 @@
1
- import { useChat } from '@ai-sdk/react'
2
1
  import type { UseChatHelpers } from '@ai-sdk/react'
3
2
  import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
4
3
  import type { ChatInit, UIMessage } from 'ai'
5
4
  import { useCallback, useEffect, useRef } from 'react'
6
5
 
6
+ import { useSnapshottingChat } from './use-snapshotting-chat'
7
+
7
8
  type ChatTransport<UI_MESSAGE extends UIMessage> = NonNullable<ChatInit<UI_MESSAGE>['transport']>
8
9
 
9
10
  export interface UseThreadChatOptions<UI_MESSAGE extends UIMessage> {
@@ -87,7 +88,7 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
87
88
  setMessages,
88
89
  addToolApprovalResponse,
89
90
  resumeStream,
90
- } = useChat<UI_MESSAGE>({
91
+ } = useSnapshottingChat<UI_MESSAGE>({
91
92
  id: chatId,
92
93
  transport,
93
94
  messages: initialMessages,