@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { InfiniteData } from '@tanstack/react-query'
|
|
3
|
+
import { useCallback, useMemo } from 'react'
|
|
4
|
+
|
|
5
|
+
import { normalizeChatMessages } from '../chat/messages'
|
|
6
|
+
|
|
7
|
+
export interface SessionMessagesPageResponse {
|
|
8
|
+
messages: unknown[]
|
|
9
|
+
hasMore: boolean
|
|
10
|
+
prevCursor: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SessionMessagesPage<TMessage> {
|
|
14
|
+
messages: TMessage[]
|
|
15
|
+
hasMore: boolean
|
|
16
|
+
prevCursor: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UseSessionMessagesConfig<TMessage extends { id: string; metadata?: { createdAt?: unknown } }> {
|
|
20
|
+
sessionId: string | null
|
|
21
|
+
queryKey: readonly unknown[]
|
|
22
|
+
fetchLatestPage: (id: string) => Promise<SessionMessagesPageResponse>
|
|
23
|
+
fetchOlderPage: (id: string, cursor: string) => Promise<SessionMessagesPageResponse>
|
|
24
|
+
pollIntervalMs: number
|
|
25
|
+
pollEnabled: boolean
|
|
26
|
+
validateMessages?: (messages: unknown[]) => Promise<TMessage[]>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseSessionMessagesReturn<TMessage> {
|
|
30
|
+
messages: TMessage[]
|
|
31
|
+
isLoading: boolean
|
|
32
|
+
hasOlderMessages: boolean
|
|
33
|
+
isLoadingOlderMessages: boolean
|
|
34
|
+
loadOlderMessages: () => Promise<void>
|
|
35
|
+
refreshLatestPage: () => Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function deduplicateAndSort<TMessage extends { id: string; metadata?: { createdAt?: unknown } }>(
|
|
39
|
+
messages: TMessage[],
|
|
40
|
+
): TMessage[] {
|
|
41
|
+
return normalizeChatMessages(Array.from(new Map(messages.map((message) => [message.id, message])).values()))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function normalizePage<TMessage extends { id: string; metadata?: { createdAt?: unknown } }>(params: {
|
|
45
|
+
response: SessionMessagesPageResponse
|
|
46
|
+
validateMessages?: (messages: unknown[]) => Promise<TMessage[]>
|
|
47
|
+
}): Promise<SessionMessagesPage<TMessage>> {
|
|
48
|
+
const validatedMessages = params.validateMessages
|
|
49
|
+
? await params.validateMessages(params.response.messages)
|
|
50
|
+
: (params.response.messages as TMessage[])
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
messages: normalizeChatMessages(validatedMessages),
|
|
54
|
+
hasMore: params.response.hasMore,
|
|
55
|
+
prevCursor: params.response.prevCursor,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useSessionMessages<TMessage extends { id: string; metadata?: { createdAt?: unknown } }>({
|
|
60
|
+
sessionId,
|
|
61
|
+
queryKey,
|
|
62
|
+
fetchLatestPage,
|
|
63
|
+
fetchOlderPage,
|
|
64
|
+
pollIntervalMs,
|
|
65
|
+
pollEnabled,
|
|
66
|
+
validateMessages,
|
|
67
|
+
}: UseSessionMessagesConfig<TMessage>): UseSessionMessagesReturn<TMessage> {
|
|
68
|
+
const queryClient = useQueryClient()
|
|
69
|
+
const infiniteQuery = useInfiniteQuery({
|
|
70
|
+
queryKey,
|
|
71
|
+
queryFn: async ({ pageParam }) => {
|
|
72
|
+
if (!sessionId) {
|
|
73
|
+
return { messages: [], hasMore: false, prevCursor: null } satisfies SessionMessagesPage<TMessage>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const response = pageParam ? await fetchOlderPage(sessionId, pageParam) : await fetchLatestPage(sessionId)
|
|
77
|
+
return await normalizePage({ response, validateMessages })
|
|
78
|
+
},
|
|
79
|
+
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.prevCursor : undefined),
|
|
80
|
+
initialPageParam: undefined as string | undefined,
|
|
81
|
+
enabled: Boolean(sessionId),
|
|
82
|
+
refetchInterval: pollEnabled ? pollIntervalMs : false,
|
|
83
|
+
refetchOnWindowFocus: false,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const messages = useMemo(() => {
|
|
87
|
+
if (!infiniteQuery.data) return []
|
|
88
|
+
return deduplicateAndSort(infiniteQuery.data.pages.flatMap((page) => page.messages))
|
|
89
|
+
}, [infiniteQuery.data])
|
|
90
|
+
|
|
91
|
+
const loadOlderMessages = useCallback(async () => {
|
|
92
|
+
if (infiniteQuery.hasNextPage && !infiniteQuery.isFetchingNextPage) {
|
|
93
|
+
await infiniteQuery.fetchNextPage()
|
|
94
|
+
}
|
|
95
|
+
}, [infiniteQuery])
|
|
96
|
+
|
|
97
|
+
const refreshLatestPage = useCallback(async () => {
|
|
98
|
+
if (!sessionId) return
|
|
99
|
+
|
|
100
|
+
const latestPage = await normalizePage({ response: await fetchLatestPage(sessionId), validateMessages })
|
|
101
|
+
|
|
102
|
+
queryClient.setQueryData(queryKey, (cached: InfiniteData<SessionMessagesPage<TMessage>> | undefined) => {
|
|
103
|
+
if (!cached || cached.pages.length === 0) {
|
|
104
|
+
return { pages: [latestPage], pageParams: [undefined] }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const pages = [...cached.pages]
|
|
108
|
+
pages[0] = latestPage
|
|
109
|
+
|
|
110
|
+
return { ...cached, pages, pageParams: cached.pageParams.length > 0 ? cached.pageParams : [undefined] }
|
|
111
|
+
})
|
|
112
|
+
}, [fetchLatestPage, queryClient, queryKey, sessionId, validateMessages])
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
messages,
|
|
116
|
+
isLoading: infiniteQuery.isLoading,
|
|
117
|
+
hasOlderMessages: infiniteQuery.hasNextPage,
|
|
118
|
+
isLoadingOlderMessages: infiniteQuery.isFetchingNextPage,
|
|
119
|
+
loadOlderMessages,
|
|
120
|
+
refreshLatestPage,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useChat } from '@ai-sdk/react'
|
|
2
|
+
import type { UseChatHelpers } from '@ai-sdk/react'
|
|
3
|
+
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
|
|
4
|
+
import type { ChatInit, UIMessage } from 'ai'
|
|
5
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
6
|
+
|
|
7
|
+
type ChatTransport<UI_MESSAGE extends UIMessage> = NonNullable<ChatInit<UI_MESSAGE>['transport']>
|
|
8
|
+
|
|
9
|
+
export interface UseThreadChatOptions<UI_MESSAGE extends UIMessage> {
|
|
10
|
+
threadId: string | null
|
|
11
|
+
initialMessages: UI_MESSAGE[]
|
|
12
|
+
transport: ChatTransport<UI_MESSAGE>
|
|
13
|
+
messageMetadataSchema?: ChatInit<UI_MESSAGE>['messageMetadataSchema']
|
|
14
|
+
dataPartSchemas?: ChatInit<UI_MESSAGE>['dataPartSchemas']
|
|
15
|
+
onMessagesSettled?: () => void
|
|
16
|
+
setThreadRunningState?: (isRunning: boolean) => void
|
|
17
|
+
notifyError?: (message: string, error: Error) => void
|
|
18
|
+
stopRemoteRun?: (threadId: string) => Promise<unknown>
|
|
19
|
+
experimentalThrottle?: number
|
|
20
|
+
sendAutomaticallyWhen?: ChatInit<UI_MESSAGE>['sendAutomaticallyWhen']
|
|
21
|
+
resume?: boolean
|
|
22
|
+
idPrefix?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseThreadChatReturn<UI_MESSAGE extends UIMessage> {
|
|
26
|
+
messages: UI_MESSAGE[]
|
|
27
|
+
status: UseChatHelpers<UI_MESSAGE>['status']
|
|
28
|
+
error: Error | undefined
|
|
29
|
+
clearError: () => void
|
|
30
|
+
sendMessage: UseChatHelpers<UI_MESSAGE>['sendMessage']
|
|
31
|
+
regenerate: UseChatHelpers<UI_MESSAGE>['regenerate']
|
|
32
|
+
stop: () => Promise<void>
|
|
33
|
+
setMessages: UseChatHelpers<UI_MESSAGE>['setMessages']
|
|
34
|
+
addToolApprovalResponse: UseChatHelpers<UI_MESSAGE>['addToolApprovalResponse']
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toError(error: unknown): Error {
|
|
38
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useThreadChat<UI_MESSAGE extends UIMessage>({
|
|
42
|
+
threadId,
|
|
43
|
+
initialMessages,
|
|
44
|
+
transport,
|
|
45
|
+
messageMetadataSchema,
|
|
46
|
+
dataPartSchemas,
|
|
47
|
+
onMessagesSettled,
|
|
48
|
+
setThreadRunningState,
|
|
49
|
+
notifyError,
|
|
50
|
+
stopRemoteRun,
|
|
51
|
+
experimentalThrottle = 50,
|
|
52
|
+
sendAutomaticallyWhen = lastAssistantMessageIsCompleteWithApprovalResponses,
|
|
53
|
+
resume = false,
|
|
54
|
+
idPrefix = 'thread',
|
|
55
|
+
}: UseThreadChatOptions<UI_MESSAGE>): UseThreadChatReturn<UI_MESSAGE> {
|
|
56
|
+
const onMessagesSettledRef = useRef(onMessagesSettled)
|
|
57
|
+
const setThreadRunningStateRef = useRef(setThreadRunningState)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
onMessagesSettledRef.current = onMessagesSettled
|
|
61
|
+
}, [onMessagesSettled])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setThreadRunningStateRef.current = setThreadRunningState
|
|
65
|
+
}, [setThreadRunningState])
|
|
66
|
+
|
|
67
|
+
const {
|
|
68
|
+
messages,
|
|
69
|
+
status,
|
|
70
|
+
error,
|
|
71
|
+
clearError,
|
|
72
|
+
sendMessage,
|
|
73
|
+
regenerate,
|
|
74
|
+
stop: stopChatStream,
|
|
75
|
+
setMessages,
|
|
76
|
+
addToolApprovalResponse,
|
|
77
|
+
} = useChat<UI_MESSAGE>({
|
|
78
|
+
id: threadId ? `${idPrefix}:${threadId}` : `${idPrefix}:none`,
|
|
79
|
+
transport,
|
|
80
|
+
messages: initialMessages,
|
|
81
|
+
messageMetadataSchema,
|
|
82
|
+
dataPartSchemas,
|
|
83
|
+
resume,
|
|
84
|
+
experimental_throttle: experimentalThrottle,
|
|
85
|
+
sendAutomaticallyWhen,
|
|
86
|
+
onFinish: () => {
|
|
87
|
+
setThreadRunningStateRef.current?.(false)
|
|
88
|
+
onMessagesSettledRef.current?.()
|
|
89
|
+
},
|
|
90
|
+
onError: (error) => {
|
|
91
|
+
if (!(error instanceof Error && error.name === 'AbortError')) {
|
|
92
|
+
notifyError?.('Chat stream failed.', toError(error))
|
|
93
|
+
}
|
|
94
|
+
setThreadRunningStateRef.current?.(false)
|
|
95
|
+
onMessagesSettledRef.current?.()
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const stop = useCallback(async () => {
|
|
100
|
+
const stopRequest = threadId && stopRemoteRun ? stopRemoteRun(threadId) : Promise.resolve(null)
|
|
101
|
+
const [stopResponse] = await Promise.allSettled([stopRequest, stopChatStream()])
|
|
102
|
+
if (stopResponse.status === 'rejected') {
|
|
103
|
+
notifyError?.('Failed to stop chat.', toError(stopResponse.reason))
|
|
104
|
+
}
|
|
105
|
+
setThreadRunningStateRef.current?.(false)
|
|
106
|
+
}, [notifyError, stopChatStream, stopRemoteRun, threadId])
|
|
107
|
+
|
|
108
|
+
return { messages, status, error, clearError, sendMessage, regenerate, stop, setMessages, addToolApprovalResponse }
|
|
109
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
const PLACEHOLDER_THREAD_PREFIX = '__placeholder_'
|
|
4
|
+
|
|
5
|
+
export interface ThreadManagementItem<TAgentId extends string = string> {
|
|
6
|
+
threadId: string
|
|
7
|
+
title: string
|
|
8
|
+
status: 'regular' | 'archived'
|
|
9
|
+
mode: 'direct' | 'group'
|
|
10
|
+
core: boolean
|
|
11
|
+
isMutable: boolean
|
|
12
|
+
agentId?: TAgentId | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UseThreadManagementOptions<TThread extends ThreadManagementItem<TAgentId>, TAgentId extends string> {
|
|
16
|
+
enabled?: boolean
|
|
17
|
+
isCreatingThread?: boolean
|
|
18
|
+
threads: TThread[]
|
|
19
|
+
coreThreads: TThread[]
|
|
20
|
+
archivedThreads: TThread[]
|
|
21
|
+
currentThreadId: string | null
|
|
22
|
+
selectedAgentId: TAgentId | null
|
|
23
|
+
isLoadingThreads: boolean
|
|
24
|
+
isRefetchingThreads: boolean
|
|
25
|
+
hasMoreThreads: boolean
|
|
26
|
+
isFetchingNextThreadsPage: boolean
|
|
27
|
+
fetchNextThreadsPageRaw: () => Promise<unknown> | void
|
|
28
|
+
createThread: () => Promise<{ id: string }>
|
|
29
|
+
renameThread: (params: { threadId: string; title: string }) => Promise<unknown>
|
|
30
|
+
archiveThread: (threadId: string) => Promise<unknown>
|
|
31
|
+
unarchiveThread: (threadId: string) => Promise<unknown>
|
|
32
|
+
deleteThread: (threadId: string) => Promise<unknown>
|
|
33
|
+
setCurrentThreadId: (threadId: string | null) => void
|
|
34
|
+
setSelectedAgentId: (agentId: TAgentId | null) => void
|
|
35
|
+
defaultThreadTitle: string
|
|
36
|
+
primaryDirectAgentId?: TAgentId
|
|
37
|
+
placeholderThreadPrefix?: string
|
|
38
|
+
findReusableThreadId?: (threads: TThread[]) => string | null
|
|
39
|
+
notifyBackgroundError?: (error: unknown, message: string) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseThreadManagementReturn<TThread extends ThreadManagementItem<TAgentId>, TAgentId extends string> {
|
|
43
|
+
threads: TThread[]
|
|
44
|
+
coreThreads: TThread[]
|
|
45
|
+
archivedThreads: TThread[]
|
|
46
|
+
currentThreadId: string | null
|
|
47
|
+
selectedAgentId: TAgentId | null
|
|
48
|
+
isLoadingThreads: boolean
|
|
49
|
+
isRefetchingThreads: boolean
|
|
50
|
+
hasMoreThreads: boolean
|
|
51
|
+
isFetchingNextThreadsPage: boolean
|
|
52
|
+
fetchNextThreadsPage: () => void
|
|
53
|
+
handleCreateNewThread: () => Promise<void>
|
|
54
|
+
handleSwitchThread: (threadId: string) => void
|
|
55
|
+
handleRenameThread: (threadId: string, newTitle: string) => Promise<void>
|
|
56
|
+
handleArchiveThread: (threadId: string) => Promise<void>
|
|
57
|
+
handleUnarchiveThread: (threadId: string) => Promise<void>
|
|
58
|
+
handleDeleteThread: (threadId: string) => Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function findReusableRegularGroupThreadId<TThread extends ThreadManagementItem>(
|
|
62
|
+
threads: TThread[],
|
|
63
|
+
defaultThreadTitle: string,
|
|
64
|
+
): string | null {
|
|
65
|
+
const draft = threads.find(
|
|
66
|
+
(thread) =>
|
|
67
|
+
thread.mode === 'group' &&
|
|
68
|
+
!thread.core &&
|
|
69
|
+
thread.status === 'regular' &&
|
|
70
|
+
thread.title.trim() === defaultThreadTitle,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return draft?.threadId ?? null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useThreadManagement<TThread extends ThreadManagementItem<TAgentId>, TAgentId extends string>({
|
|
77
|
+
enabled = true,
|
|
78
|
+
isCreatingThread = false,
|
|
79
|
+
threads,
|
|
80
|
+
coreThreads,
|
|
81
|
+
archivedThreads,
|
|
82
|
+
currentThreadId,
|
|
83
|
+
selectedAgentId,
|
|
84
|
+
isLoadingThreads,
|
|
85
|
+
isRefetchingThreads,
|
|
86
|
+
hasMoreThreads,
|
|
87
|
+
isFetchingNextThreadsPage,
|
|
88
|
+
fetchNextThreadsPageRaw,
|
|
89
|
+
createThread,
|
|
90
|
+
renameThread,
|
|
91
|
+
archiveThread,
|
|
92
|
+
unarchiveThread,
|
|
93
|
+
deleteThread,
|
|
94
|
+
setCurrentThreadId,
|
|
95
|
+
setSelectedAgentId,
|
|
96
|
+
defaultThreadTitle,
|
|
97
|
+
primaryDirectAgentId,
|
|
98
|
+
placeholderThreadPrefix = PLACEHOLDER_THREAD_PREFIX,
|
|
99
|
+
findReusableThreadId,
|
|
100
|
+
notifyBackgroundError,
|
|
101
|
+
}: UseThreadManagementOptions<TThread, TAgentId>): UseThreadManagementReturn<TThread, TAgentId> {
|
|
102
|
+
const fetchNextThreadsPage = useCallback(() => {
|
|
103
|
+
if (!enabled) return
|
|
104
|
+
if (hasMoreThreads && !isFetchingNextThreadsPage) {
|
|
105
|
+
void fetchNextThreadsPageRaw()
|
|
106
|
+
}
|
|
107
|
+
}, [enabled, fetchNextThreadsPageRaw, hasMoreThreads, isFetchingNextThreadsPage])
|
|
108
|
+
|
|
109
|
+
const allThreads = useMemo(() => [...threads, ...archivedThreads], [threads, archivedThreads])
|
|
110
|
+
const isCreatingThreadRef = useRef(false)
|
|
111
|
+
const allThreadsRef = useRef<TThread[]>(allThreads)
|
|
112
|
+
const threadsRef = useRef<TThread[]>(threads)
|
|
113
|
+
const currentThreadIdRef = useRef<string | null>(currentThreadId)
|
|
114
|
+
const createThreadRef = useRef(createThread)
|
|
115
|
+
const renameThreadRef = useRef(renameThread)
|
|
116
|
+
const archiveThreadRef = useRef(archiveThread)
|
|
117
|
+
const unarchiveThreadRef = useRef(unarchiveThread)
|
|
118
|
+
const deleteThreadRef = useRef(deleteThread)
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
allThreadsRef.current = allThreads
|
|
122
|
+
}, [allThreads])
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
threadsRef.current = threads
|
|
126
|
+
}, [threads])
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
currentThreadIdRef.current = currentThreadId
|
|
130
|
+
}, [currentThreadId])
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
createThreadRef.current = createThread
|
|
134
|
+
renameThreadRef.current = renameThread
|
|
135
|
+
archiveThreadRef.current = archiveThread
|
|
136
|
+
unarchiveThreadRef.current = unarchiveThread
|
|
137
|
+
deleteThreadRef.current = deleteThread
|
|
138
|
+
}, [archiveThread, createThread, deleteThread, renameThread, unarchiveThread])
|
|
139
|
+
|
|
140
|
+
const resolveReusableThreadId = useCallback(
|
|
141
|
+
(candidateThreads: TThread[]) =>
|
|
142
|
+
findReusableThreadId
|
|
143
|
+
? findReusableThreadId(candidateThreads)
|
|
144
|
+
: findReusableRegularGroupThreadId(candidateThreads, defaultThreadTitle),
|
|
145
|
+
[defaultThreadTitle, findReusableThreadId],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const handleSwitchThread = useCallback(
|
|
149
|
+
(threadId: string) => {
|
|
150
|
+
const resolvedThreadId = threadId.startsWith(placeholderThreadPrefix)
|
|
151
|
+
? (() => {
|
|
152
|
+
const agentId = threadId.slice(placeholderThreadPrefix.length) as TAgentId
|
|
153
|
+
const directThread = allThreadsRef.current.find(
|
|
154
|
+
(thread) => thread.mode === 'direct' && thread.agentId === agentId,
|
|
155
|
+
)
|
|
156
|
+
return directThread?.threadId
|
|
157
|
+
})()
|
|
158
|
+
: threadId
|
|
159
|
+
|
|
160
|
+
if (!resolvedThreadId) return
|
|
161
|
+
|
|
162
|
+
const target = allThreadsRef.current.find((thread) => thread.threadId === resolvedThreadId)
|
|
163
|
+
const nextAgentId = target?.mode === 'direct' && target.agentId ? target.agentId : null
|
|
164
|
+
setCurrentThreadId(resolvedThreadId)
|
|
165
|
+
setSelectedAgentId(nextAgentId)
|
|
166
|
+
},
|
|
167
|
+
[placeholderThreadPrefix, setCurrentThreadId, setSelectedAgentId],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
const handleCreateNewThread = useCallback(async () => {
|
|
171
|
+
if (isCreatingThread) return
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const existingDraftThreadId = resolveReusableThreadId(threadsRef.current)
|
|
175
|
+
if (existingDraftThreadId) {
|
|
176
|
+
setCurrentThreadId(existingDraftThreadId)
|
|
177
|
+
setSelectedAgentId(null)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = await createThreadRef.current()
|
|
182
|
+
setCurrentThreadId(result.id)
|
|
183
|
+
setSelectedAgentId(null)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
notifyBackgroundError?.(error, 'Failed to create thread.')
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
188
|
+
}, [isCreatingThread, notifyBackgroundError, resolveReusableThreadId, setCurrentThreadId, setSelectedAgentId])
|
|
189
|
+
|
|
190
|
+
const handleRenameThread = useCallback(async (threadId: string, newTitle: string) => {
|
|
191
|
+
const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
|
|
192
|
+
if (!target || !target.isMutable) return
|
|
193
|
+
await renameThreadRef.current({ threadId, title: newTitle })
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const handleArchiveThread = useCallback(
|
|
197
|
+
async (threadId: string) => {
|
|
198
|
+
const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
|
|
199
|
+
if (!target || !target.isMutable) return
|
|
200
|
+
await archiveThreadRef.current(threadId)
|
|
201
|
+
if (currentThreadIdRef.current === threadId) {
|
|
202
|
+
const remaining = threadsRef.current.filter((thread) => thread.threadId !== threadId)
|
|
203
|
+
if (remaining.length > 0) {
|
|
204
|
+
handleSwitchThread(remaining[0].threadId)
|
|
205
|
+
} else {
|
|
206
|
+
setCurrentThreadId(null)
|
|
207
|
+
setSelectedAgentId(null)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
[handleSwitchThread, setCurrentThreadId, setSelectedAgentId],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const handleUnarchiveThread = useCallback(async (threadId: string) => {
|
|
215
|
+
const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
|
|
216
|
+
if (!target || !target.isMutable) return
|
|
217
|
+
await unarchiveThreadRef.current(threadId)
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
220
|
+
const handleDeleteThread = useCallback(
|
|
221
|
+
async (threadId: string) => {
|
|
222
|
+
const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
|
|
223
|
+
if (!target || !target.isMutable) return
|
|
224
|
+
await deleteThreadRef.current(threadId)
|
|
225
|
+
if (currentThreadIdRef.current === threadId) {
|
|
226
|
+
const remaining = threadsRef.current.filter((thread) => thread.threadId !== threadId)
|
|
227
|
+
if (remaining.length > 0) {
|
|
228
|
+
handleSwitchThread(remaining[0].threadId)
|
|
229
|
+
} else {
|
|
230
|
+
setCurrentThreadId(null)
|
|
231
|
+
setSelectedAgentId(null)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
[handleSwitchThread, setCurrentThreadId, setSelectedAgentId],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
if (currentThreadId) return
|
|
240
|
+
if (isLoadingThreads) return
|
|
241
|
+
|
|
242
|
+
if (threads.length > 0) {
|
|
243
|
+
const preferredThread =
|
|
244
|
+
primaryDirectAgentId === undefined
|
|
245
|
+
? threads[0]
|
|
246
|
+
: (threads.find((thread) => thread.mode === 'direct' && thread.agentId === primaryDirectAgentId) ??
|
|
247
|
+
threads[0])
|
|
248
|
+
setCurrentThreadId(preferredThread.threadId)
|
|
249
|
+
setSelectedAgentId(preferredThread.mode === 'direct' && preferredThread.agentId ? preferredThread.agentId : null)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (isCreatingThreadRef.current) return
|
|
254
|
+
if (isCreatingThread) return
|
|
255
|
+
isCreatingThreadRef.current = true
|
|
256
|
+
|
|
257
|
+
handleCreateNewThread()
|
|
258
|
+
.catch(() => {})
|
|
259
|
+
.finally(() => {
|
|
260
|
+
isCreatingThreadRef.current = false
|
|
261
|
+
})
|
|
262
|
+
}, [
|
|
263
|
+
currentThreadId,
|
|
264
|
+
handleCreateNewThread,
|
|
265
|
+
isCreatingThread,
|
|
266
|
+
isLoadingThreads,
|
|
267
|
+
primaryDirectAgentId,
|
|
268
|
+
setCurrentThreadId,
|
|
269
|
+
setSelectedAgentId,
|
|
270
|
+
threads,
|
|
271
|
+
])
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!currentThreadId?.startsWith(placeholderThreadPrefix)) return
|
|
275
|
+
|
|
276
|
+
const agentId = currentThreadId.slice(placeholderThreadPrefix.length) as TAgentId
|
|
277
|
+
const directThread = allThreads.find((thread) => thread.mode === 'direct' && thread.agentId === agentId)
|
|
278
|
+
if (!directThread) return
|
|
279
|
+
|
|
280
|
+
setCurrentThreadId(directThread.threadId)
|
|
281
|
+
setSelectedAgentId(directThread.agentId ?? null)
|
|
282
|
+
}, [allThreads, currentThreadId, placeholderThreadPrefix, setCurrentThreadId, setSelectedAgentId])
|
|
283
|
+
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (currentThreadId !== null || selectedAgentId === null) return
|
|
286
|
+
|
|
287
|
+
const directThread = allThreads.find((thread) => thread.mode === 'direct' && thread.agentId === selectedAgentId)
|
|
288
|
+
if (!directThread) return
|
|
289
|
+
|
|
290
|
+
setCurrentThreadId(directThread.threadId)
|
|
291
|
+
}, [allThreads, currentThreadId, selectedAgentId, setCurrentThreadId])
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!currentThreadId) {
|
|
295
|
+
if (selectedAgentId !== null) {
|
|
296
|
+
setSelectedAgentId(null)
|
|
297
|
+
}
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const activeThread = allThreads.find((thread) => thread.threadId === currentThreadId)
|
|
302
|
+
if (!activeThread) return
|
|
303
|
+
|
|
304
|
+
const expectedAgentId = activeThread.mode === 'direct' && activeThread.agentId ? activeThread.agentId : null
|
|
305
|
+
if (selectedAgentId !== expectedAgentId) {
|
|
306
|
+
setSelectedAgentId(expectedAgentId)
|
|
307
|
+
}
|
|
308
|
+
}, [allThreads, currentThreadId, selectedAgentId, setSelectedAgentId])
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
threads,
|
|
312
|
+
coreThreads,
|
|
313
|
+
archivedThreads,
|
|
314
|
+
currentThreadId,
|
|
315
|
+
selectedAgentId,
|
|
316
|
+
isLoadingThreads,
|
|
317
|
+
isRefetchingThreads,
|
|
318
|
+
hasMoreThreads: enabled ? hasMoreThreads : false,
|
|
319
|
+
isFetchingNextThreadsPage,
|
|
320
|
+
fetchNextThreadsPage,
|
|
321
|
+
handleCreateNewThread,
|
|
322
|
+
handleSwitchThread,
|
|
323
|
+
handleRenameThread,
|
|
324
|
+
handleArchiveThread,
|
|
325
|
+
handleUnarchiveThread,
|
|
326
|
+
handleDeleteThread,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SerializableExecutionPlanTask } from '@lota-sdk/shared/schemas/execution-plan'
|
|
2
|
+
import type { ExecutionPlanToolResultData } from '@lota-sdk/shared/schemas/tools'
|
|
3
|
+
|
|
4
|
+
export function getLatestExecutionPlanResult(output: unknown): ExecutionPlanToolResultData | null {
|
|
5
|
+
if (output && typeof output === 'object' && 'hasPlan' in output) {
|
|
6
|
+
return output as ExecutionPlanToolResultData
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (Array.isArray(output)) {
|
|
10
|
+
for (let index = output.length - 1; index >= 0; index -= 1) {
|
|
11
|
+
const candidate = getLatestExecutionPlanResult(output[index])
|
|
12
|
+
if (candidate) return candidate
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getExecutionPlanActionLabel(action: string, isRunning: boolean): string {
|
|
20
|
+
if (isRunning) return 'Updating execution plan'
|
|
21
|
+
|
|
22
|
+
switch (action) {
|
|
23
|
+
case 'created':
|
|
24
|
+
return 'Execution plan created'
|
|
25
|
+
case 'replaced':
|
|
26
|
+
return 'Execution plan replaced'
|
|
27
|
+
case 'task-status-updated':
|
|
28
|
+
return 'Execution plan updated'
|
|
29
|
+
case 'task-restarted':
|
|
30
|
+
return 'Execution task restarted'
|
|
31
|
+
case 'loaded':
|
|
32
|
+
return 'Execution plan loaded'
|
|
33
|
+
default:
|
|
34
|
+
return 'Execution plan'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getExecutionPlanStatusLabel(status: string | null | undefined): string {
|
|
39
|
+
if (status === 'draft') return 'Draft'
|
|
40
|
+
if (status === 'executing') return 'Executing'
|
|
41
|
+
if (status === 'blocked') return 'Blocked'
|
|
42
|
+
if (status === 'completed') return 'Completed'
|
|
43
|
+
if (status === 'aborted') return 'Aborted'
|
|
44
|
+
return 'Idle'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getExecutionPlanOwnerLabel(
|
|
48
|
+
task: SerializableExecutionPlanTask,
|
|
49
|
+
displayNameByOwnerRef: Record<string, string> = {},
|
|
50
|
+
): string {
|
|
51
|
+
if (task.ownerType === 'agent' && task.ownerRef in displayNameByOwnerRef) {
|
|
52
|
+
return displayNameByOwnerRef[task.ownerRef]
|
|
53
|
+
}
|
|
54
|
+
if (task.ownerType === 'user') return 'User'
|
|
55
|
+
return task.ownerRef
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildExecutionPlanToolViewModel(params: { input: unknown; isRunning: boolean; output: unknown }) {
|
|
59
|
+
const result = getLatestExecutionPlanResult(params.output)
|
|
60
|
+
const plan = result?.plan ?? null
|
|
61
|
+
const inputTitle =
|
|
62
|
+
params.input &&
|
|
63
|
+
typeof params.input === 'object' &&
|
|
64
|
+
'title' in params.input &&
|
|
65
|
+
typeof params.input.title === 'string'
|
|
66
|
+
? params.input.title.trim()
|
|
67
|
+
: ''
|
|
68
|
+
const title = plan && plan.title.trim() ? plan.title.trim() : inputTitle || 'Execution plan'
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
actionLabel: getExecutionPlanActionLabel(result?.action ?? 'none', params.isRunning),
|
|
72
|
+
hasPlan: plan !== null,
|
|
73
|
+
latestEvent: plan ? plan.recentEvents.at(-1)?.message.trim() : undefined,
|
|
74
|
+
plan,
|
|
75
|
+
progressSummary: plan ? `${plan.progress.completed}/${plan.progress.total} complete` : 'No active plan',
|
|
76
|
+
result,
|
|
77
|
+
statusLabel: getExecutionPlanStatusLabel(result?.status ?? plan?.status),
|
|
78
|
+
title,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ToolRenderMode = 'framed' | 'standalone'
|
|
2
|
+
|
|
3
|
+
export type ToolRegistryEntry<TComponent> = { component: TComponent; renderMode?: ToolRenderMode }
|
|
4
|
+
|
|
5
|
+
export function createToolRegistry<const TRegistry extends Record<string, ToolRegistryEntry<unknown>>>(
|
|
6
|
+
registry: TRegistry,
|
|
7
|
+
) {
|
|
8
|
+
type ToolName = keyof TRegistry & string
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
registry,
|
|
12
|
+
isRegisteredTool(toolName: string): toolName is ToolName {
|
|
13
|
+
return toolName in registry
|
|
14
|
+
},
|
|
15
|
+
getToolComponent<TName extends ToolName>(toolName: TName): TRegistry[TName]['component'] | undefined {
|
|
16
|
+
return registry[toolName]?.component
|
|
17
|
+
},
|
|
18
|
+
resolveToolRenderMode(toolName: ToolName): ToolRenderMode {
|
|
19
|
+
return registry[toolName]?.renderMode ?? 'framed'
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|