@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 +57 -0
- package/src/chat/attachments.ts +83 -0
- package/src/chat/index.ts +4 -0
- package/src/chat/message-parts.ts +101 -0
- package/src/chat/messages.ts +39 -0
- package/src/chat/transport.ts +8 -0
- package/src/index.ts +3 -0
- package/src/runtime/active-thread-helpers.ts +59 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/session-common.ts +27 -0
- package/src/runtime/use-active-thread-session.ts +264 -0
- package/src/runtime/use-session-messages.ts +122 -0
- package/src/runtime/use-thread-chat.ts +109 -0
- package/src/runtime/use-thread-management.ts +328 -0
- package/src/tools/execution-plan.ts +80 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/tool-registry.ts +22 -0
- package/src/tools/user-questions.ts +77 -0
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,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,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,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
|
+
}
|