@lota-sdk/ui 0.1.5

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 ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@lota-sdk/ui",
3
+ "version": "0.1.5",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "files": [
8
+ "src"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "bun": "./src/index.ts",
13
+ "import": "./src/index.ts",
14
+ "types": "./src/index.ts"
15
+ },
16
+ "./chat/*": {
17
+ "bun": "./src/chat/*.ts",
18
+ "import": "./src/chat/*.ts",
19
+ "types": "./src/chat/*.ts"
20
+ },
21
+ "./runtime/*": {
22
+ "bun": "./src/runtime/*.ts",
23
+ "import": "./src/runtime/*.ts",
24
+ "types": "./src/runtime/*.ts"
25
+ },
26
+ "./tools/*": {
27
+ "bun": "./src/tools/*.ts",
28
+ "import": "./src/tools/*.ts",
29
+ "types": "./src/tools/*.ts"
30
+ }
31
+ },
32
+ "scripts": {
33
+ "lint": "node ../node_modules/oxlint/bin/oxlint --fix -c ../oxlint.config.ts src",
34
+ "format": "bunx oxfmt src",
35
+ "typecheck": "bunx tsgo --noEmit",
36
+ "test:unit": "bun test ../tests/unit/ui"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "dependencies": {
43
+ "@lota-sdk/shared": "0.1.0",
44
+ "ai": "^6.0.116"
45
+ },
46
+ "peerDependencies": {
47
+ "@ai-sdk/react": "^3.0.118",
48
+ "@tanstack/react-query": "^5.90.21",
49
+ "react": "^19.2.0"
50
+ },
51
+ "devDependencies": {
52
+ "@ai-sdk/react": "^3.0.118",
53
+ "@tanstack/react-query": "^5.90.21",
54
+ "@types/react": "^19.2.14",
55
+ "react": "^19.2.4"
56
+ }
57
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ ATTACHMENT_MAX_FILE_SIZE_BYTES,
3
+ ATTACHMENT_MAX_TOTAL_SIZE_BYTES,
4
+ inferContentType,
5
+ isSupportedAttachment,
6
+ } from '@lota-sdk/shared/constants/attachments'
7
+ import type { FileUIPart } from 'ai'
8
+
9
+ type UploadedAttachmentPayload = {
10
+ filename: string
11
+ mediaType: string
12
+ sizeBytes: number
13
+ storageKey: string
14
+ url: string
15
+ }
16
+
17
+ async function toUploadFile(filePart: FileUIPart): Promise<File> {
18
+ if (!filePart.url) {
19
+ throw new Error('Attachment payload is missing file data.')
20
+ }
21
+
22
+ const response = await fetch(filePart.url)
23
+ if (!response.ok) {
24
+ throw new Error(`Failed to read attachment bytes (${response.status}).`)
25
+ }
26
+
27
+ const blob = await response.blob()
28
+ const filename = filePart.filename?.trim() || 'attachment'
29
+ const inferredFile = new File([blob], filename, { type: blob.type || 'application/octet-stream' })
30
+ const mediaType = filePart.mediaType.trim() || inferContentType(inferredFile)
31
+
32
+ return new File([inferredFile], filename, { type: mediaType })
33
+ }
34
+
35
+ export async function uploadComposerFiles(params: {
36
+ files: FileUIPart[] | undefined
37
+ uploadFile: (file: File) => Promise<{ attachment: UploadedAttachmentPayload }>
38
+ maxFileSizeBytes?: number
39
+ maxTotalSizeBytes?: number
40
+ isAttachmentSupported?: (file: File) => boolean
41
+ toFile?: (filePart: FileUIPart) => Promise<File>
42
+ }): Promise<FileUIPart[]> {
43
+ if (!params.files || params.files.length === 0) return []
44
+
45
+ const toFile = params.toFile ?? toUploadFile
46
+ const uploads = await Promise.all(params.files.map((filePart) => toFile(filePart)))
47
+ const maxFileSizeBytes = params.maxFileSizeBytes ?? ATTACHMENT_MAX_FILE_SIZE_BYTES
48
+ const maxTotalSizeBytes = params.maxTotalSizeBytes ?? ATTACHMENT_MAX_TOTAL_SIZE_BYTES
49
+ const isAttachmentSupported = params.isAttachmentSupported ?? isSupportedAttachment
50
+
51
+ let totalSizeBytes = 0
52
+ for (const file of uploads) {
53
+ if (!isAttachmentSupported(file)) {
54
+ throw new Error(`Unsupported attachment type: ${file.name}`)
55
+ }
56
+ if (file.size > maxFileSizeBytes) {
57
+ throw new Error(`Attachment exceeds ${Math.floor(maxFileSizeBytes / 1024 / 1024)}MB limit.`)
58
+ }
59
+ totalSizeBytes += file.size
60
+ }
61
+
62
+ if (totalSizeBytes > maxTotalSizeBytes) {
63
+ throw new Error('Total attachment size exceeds allowed limit.')
64
+ }
65
+
66
+ return await Promise.all(
67
+ uploads.map(async (file) => {
68
+ const response = await params.uploadFile(file)
69
+ return {
70
+ type: 'file',
71
+ mediaType: response.attachment.mediaType,
72
+ filename: response.attachment.filename,
73
+ url: response.attachment.url,
74
+ providerMetadata: {
75
+ lota: {
76
+ attachmentStorageKey: response.attachment.storageKey,
77
+ attachmentSizeBytes: response.attachment.sizeBytes,
78
+ },
79
+ },
80
+ } satisfies FileUIPart
81
+ }),
82
+ )
83
+ }
@@ -0,0 +1,4 @@
1
+ export * from './attachments'
2
+ export * from './message-parts'
3
+ export * from './messages'
4
+ export * from './transport'
@@ -0,0 +1,101 @@
1
+ import { isToolUIPart } from 'ai'
2
+ import type { UIDataTypes, UIMessagePart, UITools } from 'ai'
3
+
4
+ export type MessagePartEntry<TPart> = { part: TPart; index: number }
5
+
6
+ export type ToolMessagePart<DATA_PARTS extends UIDataTypes = UIDataTypes, TOOLS extends UITools = UITools> = Extract<
7
+ UIMessagePart<DATA_PARTS, TOOLS>,
8
+ { type: `tool-${string}` } | { type: 'dynamic-tool' }
9
+ >
10
+
11
+ function filterRedactedReasoning(text: string): string {
12
+ return text.replace(/\[REDACTED\]/gi, '').trim()
13
+ }
14
+
15
+ export function readToolPart<DATA_PARTS extends UIDataTypes = UIDataTypes, TOOLS extends UITools = UITools>(
16
+ part: UIMessagePart<DATA_PARTS, TOOLS>,
17
+ ): ToolMessagePart<DATA_PARTS, TOOLS> | null {
18
+ if (!isToolUIPart(part)) return null
19
+ return part as ToolMessagePart<DATA_PARTS, TOOLS>
20
+ }
21
+
22
+ function isTextLikeContent<DATA_PARTS extends UIDataTypes, TOOLS extends UITools>(
23
+ part: UIMessagePart<DATA_PARTS, TOOLS>,
24
+ ): boolean {
25
+ if (part.type === 'text' || part.type === 'reasoning') {
26
+ return 'text' in part && typeof part.text === 'string' && part.text.length > 0
27
+ }
28
+ if (part.type === 'file' || part.type === 'source-url' || part.type === 'source-document') {
29
+ return true
30
+ }
31
+ return readToolPart(part) !== null
32
+ }
33
+
34
+ export function consumeReasoningGroup<DATA_PARTS extends UIDataTypes, TOOLS extends UITools>(
35
+ entries: MessagePartEntry<UIMessagePart<DATA_PARTS, TOOLS>>[],
36
+ startIndex: number,
37
+ isMessageStreaming: boolean,
38
+ ): { nextEntryIndex: number; stablePartIndex: number; text: string; isStreaming: boolean } | null {
39
+ const entry = entries.at(startIndex)
40
+ if (!entry || entry.part.type !== 'reasoning') return null
41
+
42
+ const reasoningTexts: string[] = []
43
+ let hasStreamingReasoningPart = false
44
+ let nextEntryIndex = startIndex
45
+
46
+ while (nextEntryIndex < entries.length) {
47
+ const currentEntry = entries.at(nextEntryIndex)
48
+ const part = currentEntry?.part
49
+ if (!part || part.type !== 'reasoning' || !('text' in part) || typeof part.text !== 'string') {
50
+ break
51
+ }
52
+
53
+ const reasoningText = filterRedactedReasoning(part.text)
54
+ if (reasoningText.length > 0) {
55
+ reasoningTexts.push(reasoningText)
56
+ }
57
+ if ('state' in part && part.state === 'streaming') {
58
+ hasStreamingReasoningPart = true
59
+ }
60
+ nextEntryIndex += 1
61
+ }
62
+
63
+ const hasLaterTextLikePart = entries.slice(nextEntryIndex).some((candidate) => isTextLikeContent(candidate.part))
64
+
65
+ return {
66
+ nextEntryIndex,
67
+ stablePartIndex: entry.index,
68
+ text: reasoningTexts.join('\n\n'),
69
+ isStreaming: (hasStreamingReasoningPart || isMessageStreaming) && !hasLaterTextLikePart,
70
+ }
71
+ }
72
+
73
+ export function consumeTextGroup<DATA_PARTS extends UIDataTypes, TOOLS extends UITools>(
74
+ entries: MessagePartEntry<UIMessagePart<DATA_PARTS, TOOLS>>[],
75
+ startIndex: number,
76
+ options: { sanitizeText?: (value: string) => string } = {},
77
+ ): { nextEntryIndex: number; stablePartIndex: number; text: string } | null {
78
+ const entry = entries.at(startIndex)
79
+ if (!entry || entry.part.type !== 'text') return null
80
+
81
+ const sanitizeText = options.sanitizeText ?? ((value: string) => value)
82
+ const textSegments: string[] = []
83
+ let nextEntryIndex = startIndex
84
+
85
+ while (nextEntryIndex < entries.length) {
86
+ const currentEntry = entries.at(nextEntryIndex)
87
+ const part = currentEntry?.part
88
+ if (!part || part.type !== 'text' || !('text' in part) || typeof part.text !== 'string') {
89
+ break
90
+ }
91
+
92
+ const text = sanitizeText(part.text)
93
+ if (text.length > 0) {
94
+ textSegments.push(text)
95
+ }
96
+
97
+ nextEntryIndex += 1
98
+ }
99
+
100
+ return { nextEntryIndex, stablePartIndex: entry.index, text: textSegments.join('\n\n') }
101
+ }
@@ -0,0 +1,39 @@
1
+ import { getMessageCreatedAt, withMessageCreatedAt } from '@lota-sdk/shared/runtime/chat-message-metadata'
2
+
3
+ interface TimestampedMessage {
4
+ id: string
5
+ metadata?: { createdAt?: unknown }
6
+ }
7
+
8
+ function normalizeMessage<TMessage extends TimestampedMessage>(message: TMessage): TMessage {
9
+ return withMessageCreatedAt(message)
10
+ }
11
+
12
+ function compareChatMessages<TMessage extends TimestampedMessage>(left: TMessage, right: TMessage): number {
13
+ const leftTime = getMessageCreatedAt(left, { fallback: 0 })
14
+ const rightTime = getMessageCreatedAt(right, { fallback: 0 })
15
+ if (leftTime !== rightTime) return leftTime - rightTime
16
+ return left.id.localeCompare(right.id)
17
+ }
18
+
19
+ function sortChatMessages<TMessage extends TimestampedMessage>(messages: readonly TMessage[]): TMessage[] {
20
+ return [...messages].sort(compareChatMessages)
21
+ }
22
+
23
+ export function normalizeChatMessages<TMessage extends TimestampedMessage>(messages: readonly TMessage[]): TMessage[] {
24
+ return sortChatMessages(messages.map((message) => normalizeMessage(message)))
25
+ }
26
+
27
+ export function mergeChatMessages<TMessage extends TimestampedMessage>(
28
+ ...messageLists: ReadonlyArray<readonly TMessage[]>
29
+ ): TMessage[] {
30
+ const messagesById = new Map<string, TMessage>()
31
+
32
+ for (const messages of messageLists) {
33
+ for (const message of messages) {
34
+ messagesById.set(message.id, message)
35
+ }
36
+ }
37
+
38
+ return sortChatMessages(Array.from(messagesById.values()))
39
+ }
@@ -0,0 +1,8 @@
1
+ import { DefaultChatTransport } from 'ai'
2
+ import type { HttpChatTransportInitOptions, UIMessage } from 'ai'
3
+
4
+ export type ChatTransportOptions<UI_MESSAGE extends UIMessage> = HttpChatTransportInitOptions<UI_MESSAGE>
5
+
6
+ export function createChatTransport<UI_MESSAGE extends UIMessage>(options: ChatTransportOptions<UI_MESSAGE>) {
7
+ return new DefaultChatTransport<UI_MESSAGE>(options)
8
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './chat'
2
+ export * from './runtime'
3
+ export * from './tools'
@@ -0,0 +1,59 @@
1
+ import type { FileUIPart, UIMessage } from 'ai'
2
+
3
+ export type ComposerMessage<TFile extends FileUIPart = FileUIPart> = { text: string; files?: TFile[] }
4
+
5
+ export function validateComposerMessage<TFile extends FileUIPart = FileUIPart>(
6
+ message: ComposerMessage<TFile>,
7
+ ): { textContent: string; files: TFile[] } | null {
8
+ const textContent = message.text.trim()
9
+ const files = message.files ?? []
10
+ if (textContent.length === 0 && files.length === 0) return null
11
+ return { textContent, files }
12
+ }
13
+
14
+ export function getLatestAssistantMessage<TMessage extends UIMessage>(messages: TMessage[]): TMessage | null {
15
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
16
+ const message = messages[index]
17
+ if (message.role === 'assistant') return message
18
+ }
19
+ return null
20
+ }
21
+
22
+ export function readLatestDataPart<TData = unknown, TMessage extends UIMessage = UIMessage>(
23
+ messages: TMessage[],
24
+ dataPartType: `data-${string}`,
25
+ ): TData | null {
26
+ for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
27
+ const message = messages[messageIndex]
28
+ if (message.role !== 'assistant') continue
29
+
30
+ for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex -= 1) {
31
+ const part = message.parts[partIndex]
32
+ if (part.type !== dataPartType || !('data' in part)) continue
33
+ return part.data as TData
34
+ }
35
+ }
36
+
37
+ return null
38
+ }
39
+
40
+ export function readLatestToolOutput<TOutput = unknown, TMessage extends UIMessage = UIMessage>(
41
+ messages: TMessage[],
42
+ toolName: string,
43
+ ): TOutput | null {
44
+ const toolPartType = `tool-${toolName}`
45
+
46
+ for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
47
+ const message = messages[messageIndex]
48
+ if (message.role !== 'assistant') continue
49
+
50
+ for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex -= 1) {
51
+ const part = message.parts[partIndex]
52
+ if (part.type !== toolPartType) continue
53
+ if (!('state' in part) || part.state !== 'output-available' || !('output' in part)) continue
54
+ return part.output as TOutput
55
+ }
56
+ }
57
+
58
+ return null
59
+ }
@@ -0,0 +1,6 @@
1
+ export * from './active-thread-helpers'
2
+ export * from './session-common'
3
+ export * from './use-active-thread-session'
4
+ export * from './use-session-messages'
5
+ export * from './use-thread-chat'
6
+ export * from './use-thread-management'
@@ -0,0 +1,27 @@
1
+ export type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error'
2
+
3
+ export interface RuntimeLoadingState {
4
+ isLoading: boolean
5
+ isRefetching: boolean
6
+ hasCachedData: boolean
7
+ }
8
+
9
+ export const ACTIVE_SESSION_POLL_INTERVAL_MS = 15_000
10
+
11
+ export function isRuntimeRunning(status: ChatStatus): boolean {
12
+ return status === 'streaming' || status === 'submitted'
13
+ }
14
+
15
+ export function buildRuntimeLoadingState(params: {
16
+ sessionId: string | null
17
+ isPendingMessages: boolean
18
+ isRefetchingMessages: boolean
19
+ hasCachedData: boolean
20
+ isRunning: boolean
21
+ }): RuntimeLoadingState {
22
+ return {
23
+ isLoading: Boolean(params.sessionId) && params.isPendingMessages && !params.hasCachedData,
24
+ isRefetching: params.hasCachedData && params.isRefetchingMessages && !params.isRunning,
25
+ hasCachedData: params.hasCachedData,
26
+ }
27
+ }
@@ -0,0 +1,264 @@
1
+ import { getMessageCreatedAt } from '@lota-sdk/shared/runtime/chat-message-metadata'
2
+ import type { FileUIPart, UIMessage } from 'ai'
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
4
+
5
+ import { mergeChatMessages } from '../chat/messages'
6
+ import { getLatestAssistantMessage, validateComposerMessage } from './active-thread-helpers'
7
+ import { buildRuntimeLoadingState, isRuntimeRunning } from './session-common'
8
+ import type { ChatStatus, RuntimeLoadingState } from './session-common'
9
+ import type { SessionMessagesPageResponse } from './use-session-messages'
10
+ import { useSessionMessages } from './use-session-messages'
11
+ import type { UseThreadChatOptions } from './use-thread-chat'
12
+ import { useThreadChat } from './use-thread-chat'
13
+
14
+ type FileMessagePart<TMessage extends UIMessage> = Extract<TMessage['parts'][number], { type: 'file' }>
15
+
16
+ interface ActiveThreadLike {
17
+ isRunning?: boolean
18
+ isCompacting?: boolean
19
+ }
20
+
21
+ export interface UseActiveThreadSessionOptions<
22
+ TMessage extends UIMessage & { metadata?: { createdAt?: unknown } },
23
+ TThread extends ActiveThreadLike,
24
+ TFile extends FileUIPart = FileUIPart,
25
+ TTrackerState = never,
26
+ > {
27
+ threadId: string | null
28
+ thread: TThread | null
29
+ sessionMessagesQueryKey: readonly unknown[]
30
+ fetchLatestPage: (threadId: string) => Promise<SessionMessagesPageResponse>
31
+ fetchOlderPage: (threadId: string, cursor: string) => Promise<SessionMessagesPageResponse>
32
+ validateMessages?: (messages: unknown[]) => Promise<TMessage[]>
33
+ pollIntervalMs: number
34
+ pollEnabled: boolean
35
+ threadChat: Omit<UseThreadChatOptions<TMessage>, 'threadId' | 'initialMessages' | 'onMessagesSettled'>
36
+ validateMessage?: (message: { text: string; files?: TFile[] }) => { textContent: string; files: TFile[] } | null
37
+ uploadFiles?: (params: { threadId: string; files: TFile[] }) => Promise<Array<FileMessagePart<TMessage>>>
38
+ buildUserMessage: (params: { textContent: string; fileParts: Array<FileMessagePart<TMessage>> }) => TMessage
39
+ onBeforeSend?: (params: {
40
+ threadId: string
41
+ textContent: string
42
+ messages: TMessage[]
43
+ thread: TThread | null
44
+ }) => Promise<void> | void
45
+ onMessagesUpdated?: (messages: TMessage[]) => void
46
+ readTrackerState?: (messages: TMessage[]) => TTrackerState | null
47
+ onTrackerState?: (params: { threadId: string; trackerState: TTrackerState }) => void
48
+ notifyError?: (message: string, error: unknown) => void
49
+ isThreadBusy?: boolean
50
+ }
51
+
52
+ export interface UseActiveThreadSessionReturn<TMessage extends UIMessage, TFile extends FileUIPart = FileUIPart> {
53
+ messages: TMessage[]
54
+ status: ChatStatus
55
+ error: Error | undefined
56
+ clearError: () => void
57
+ regenerate: () => Promise<void>
58
+ sendMessage: (message: { text: string; files?: TFile[] }) => Promise<void>
59
+ onStop: () => Promise<void>
60
+ threadLoadingState: RuntimeLoadingState
61
+ hasOlderMessages: boolean
62
+ isLoadingOlderMessages: boolean
63
+ loadOlderMessages: () => Promise<void>
64
+ addToolApprovalResponse: (params: { id: string; approved: boolean; reason?: string }) => void
65
+ }
66
+
67
+ export function useActiveThreadSession<
68
+ TMessage extends UIMessage & { metadata?: { createdAt?: unknown } },
69
+ TThread extends ActiveThreadLike,
70
+ TFile extends FileUIPart = FileUIPart,
71
+ TTrackerState = never,
72
+ >({
73
+ threadId,
74
+ thread,
75
+ sessionMessagesQueryKey,
76
+ fetchLatestPage,
77
+ fetchOlderPage,
78
+ validateMessages,
79
+ pollIntervalMs,
80
+ pollEnabled,
81
+ threadChat,
82
+ validateMessage = validateComposerMessage,
83
+ uploadFiles,
84
+ buildUserMessage,
85
+ onBeforeSend,
86
+ onMessagesUpdated,
87
+ readTrackerState,
88
+ onTrackerState,
89
+ notifyError,
90
+ isThreadBusy: externalThreadBusy = false,
91
+ }: UseActiveThreadSessionOptions<TMessage, TThread, TFile, TTrackerState>): UseActiveThreadSessionReturn<
92
+ TMessage,
93
+ TFile
94
+ > {
95
+ const {
96
+ messages: sessionMessages,
97
+ isLoading: isLoadingMessages,
98
+ hasOlderMessages,
99
+ isLoadingOlderMessages,
100
+ loadOlderMessages,
101
+ refreshLatestPage,
102
+ } = useSessionMessages<TMessage>({
103
+ sessionId: threadId,
104
+ queryKey: sessionMessagesQueryKey,
105
+ fetchLatestPage,
106
+ fetchOlderPage,
107
+ pollIntervalMs,
108
+ pollEnabled,
109
+ validateMessages,
110
+ })
111
+
112
+ const {
113
+ messages,
114
+ status: chatStatus,
115
+ error: chatError,
116
+ clearError,
117
+ sendMessage: sendChatMessage,
118
+ regenerate: regenerateChat,
119
+ stop: stopChatStream,
120
+ setMessages,
121
+ addToolApprovalResponse,
122
+ } = useThreadChat<TMessage>({
123
+ ...threadChat,
124
+ threadId,
125
+ initialMessages: sessionMessages,
126
+ onMessagesSettled: refreshLatestPage,
127
+ })
128
+
129
+ const hasRemoteRun = Boolean(thread?.isRunning)
130
+ const status = hasRemoteRun && !isRuntimeRunning(chatStatus) ? 'streaming' : (chatStatus as ChatStatus)
131
+ const error = hasRemoteRun ? undefined : chatError
132
+ const isRunning = isRuntimeRunning(status)
133
+ const isThreadBusy = isRunning || Boolean(thread?.isCompacting) || externalThreadBusy
134
+
135
+ const lastSyncedRef = useRef(sessionMessages)
136
+ useEffect(() => {
137
+ if (isRunning && messages.length > 0) return
138
+ if (lastSyncedRef.current === sessionMessages) return
139
+ lastSyncedRef.current = sessionMessages
140
+ if (sessionMessages.length === 0) return
141
+
142
+ const latestStreamedAssistant = getLatestAssistantMessage(messages)
143
+ const latestPersistedAssistant = getLatestAssistantMessage(sessionMessages)
144
+ const persistedMessageIds = new Set(sessionMessages.map((message) => message.id))
145
+ const hasPersistedLatestStreamedMessage = latestStreamedAssistant
146
+ ? persistedMessageIds.has(latestStreamedAssistant.id)
147
+ : true
148
+ const hasPersistedCaughtUpByTimestamp =
149
+ latestStreamedAssistant && latestPersistedAssistant
150
+ ? getMessageCreatedAt(latestPersistedAssistant, { fallback: 0 }) >=
151
+ getMessageCreatedAt(latestStreamedAssistant, { fallback: 0 })
152
+ : false
153
+
154
+ if (hasPersistedLatestStreamedMessage || hasPersistedCaughtUpByTimestamp) {
155
+ setMessages(sessionMessages)
156
+ return
157
+ }
158
+
159
+ setMessages(mergeChatMessages(messages, sessionMessages))
160
+ }, [isRunning, messages, sessionMessages, setMessages])
161
+
162
+ useEffect(() => {
163
+ onMessagesUpdated?.(messages)
164
+ }, [messages, onMessagesUpdated])
165
+
166
+ useEffect(() => {
167
+ if (!threadId || !readTrackerState || !onTrackerState) return
168
+
169
+ const trackerState = readTrackerState(messages)
170
+ if (trackerState === null) return
171
+
172
+ onTrackerState({ threadId, trackerState })
173
+ }, [messages, onTrackerState, readTrackerState, threadId])
174
+
175
+ const dispatchMessageNow = useCallback(
176
+ async (message: { text: string; files?: TFile[] }): Promise<boolean> => {
177
+ if (!threadId) {
178
+ notifyError?.('No thread selected.', new Error('No thread selected.'))
179
+ return false
180
+ }
181
+
182
+ const validated = validateMessage(message)
183
+ if (!validated) {
184
+ notifyError?.('Message must include text or at least one attachment.', new Error('Message is empty.'))
185
+ return false
186
+ }
187
+
188
+ const { textContent, files } = validated
189
+ let fileParts: Array<FileMessagePart<TMessage>> = []
190
+ if (files.length > 0 && uploadFiles) {
191
+ try {
192
+ fileParts = await uploadFiles({ threadId, files })
193
+ } catch (error) {
194
+ notifyError?.('Failed to prepare attachments.', error)
195
+ return false
196
+ }
197
+ }
198
+
199
+ try {
200
+ await onBeforeSend?.({ threadId, textContent, messages, thread })
201
+ await sendChatMessage(buildUserMessage({ textContent, fileParts }))
202
+ return true
203
+ } catch (error) {
204
+ notifyError?.('Failed to send message.', error)
205
+ return false
206
+ }
207
+ },
208
+ [
209
+ buildUserMessage,
210
+ messages,
211
+ notifyError,
212
+ onBeforeSend,
213
+ sendChatMessage,
214
+ thread,
215
+ threadId,
216
+ uploadFiles,
217
+ validateMessage,
218
+ ],
219
+ )
220
+
221
+ const sendMessage = useCallback(
222
+ async (message: { text: string; files?: TFile[] }) => {
223
+ if (!threadId || isThreadBusy) return
224
+ await dispatchMessageNow(message)
225
+ },
226
+ [dispatchMessageNow, isThreadBusy, threadId],
227
+ )
228
+
229
+ const regenerate = useCallback(async () => {
230
+ try {
231
+ await regenerateChat()
232
+ } catch (error) {
233
+ notifyError?.('Failed to regenerate message.', error)
234
+ }
235
+ }, [notifyError, regenerateChat])
236
+
237
+ const hasCachedData = messages.length > 0 || sessionMessages.length > 0
238
+ const threadLoadingState = useMemo(
239
+ () =>
240
+ buildRuntimeLoadingState({
241
+ sessionId: threadId,
242
+ isPendingMessages: isLoadingMessages,
243
+ isRefetchingMessages: false,
244
+ hasCachedData,
245
+ isRunning: isThreadBusy,
246
+ }),
247
+ [hasCachedData, isLoadingMessages, isThreadBusy, threadId],
248
+ )
249
+
250
+ return {
251
+ messages,
252
+ status,
253
+ error,
254
+ clearError,
255
+ regenerate,
256
+ sendMessage,
257
+ onStop: stopChatStream,
258
+ threadLoadingState,
259
+ hasOlderMessages,
260
+ isLoadingOlderMessages,
261
+ loadOlderMessages,
262
+ addToolApprovalResponse,
263
+ }
264
+ }