From 512aa079633c0b6a80787771676fcc7af868e306 Mon Sep 17 00:00:00 2001 From: aliang <1098486429@qq.com> Date: Sat, 17 Aug 2024 14:36:06 +0800 Subject: [PATCH] feat(ui): integrate thread api in Answer Engine (#2881) * feat(ui): integrate thread endpoint * docQuery * update: operation context * update * update: threadRunLoading * rename fields * update: threadMessages * update: messageAttachmentCode * update * checkoutt llama.cpp * [autofix.ci] apply automated fixes * update: url pattern * update: remove testing code * update * update: display score * Update ee/tabby-ui/lib/tabby/gql.ts * update: add slug * update: loading & scroll * update * fix(ui): fix thread message complete event handling (#2900) * [autofix.ci] apply automated fixes * update --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Meng Zhang --- .../search/components/messages-skeleton.tsx | 13 + ee/tabby-ui/app/search/components/search.tsx | 773 ++++++++++-------- .../components/chat/question-answer.tsx | 6 +- ee/tabby-ui/lib/hooks/use-thread-run.ts | 305 +++++++ ee/tabby-ui/lib/tabby/gql.ts | 30 +- ee/tabby-ui/package.json | 2 + ee/tabby-webserver/development/Caddyfile | 1 + pnpm-lock.yaml | 56 +- 8 files changed, 829 insertions(+), 357 deletions(-) create mode 100644 ee/tabby-ui/app/search/components/messages-skeleton.tsx create mode 100644 ee/tabby-ui/lib/hooks/use-thread-run.ts diff --git a/ee/tabby-ui/app/search/components/messages-skeleton.tsx b/ee/tabby-ui/app/search/components/messages-skeleton.tsx new file mode 100644 index 000000000000..346f10099d25 --- /dev/null +++ b/ee/tabby-ui/app/search/components/messages-skeleton.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export function MessagesSkeleton() { + return ( +
+
+ + +
+ +
+ ) +} diff --git a/ee/tabby-ui/app/search/components/search.tsx b/ee/tabby-ui/app/search/components/search.tsx index 2a3649cc9623..21fe47a9b688 100644 --- a/ee/tabby-ui/app/search/components/search.tsx +++ b/ee/tabby-ui/app/search/components/search.tsx @@ -13,7 +13,6 @@ import { import Image from 'next/image' import { useRouter } from 'next/navigation' import defaultFavicon from '@/assets/default-favicon.png' -import { Message } from 'ai' import DOMPurify from 'dompurify' import he from 'he' import { marked } from 'marked' @@ -26,15 +25,7 @@ import { useEnableDeveloperMode, useEnableSearch } from '@/lib/experiment-flags' import { useCurrentTheme } from '@/lib/hooks/use-current-theme' import { useLatest } from '@/lib/hooks/use-latest' import { useIsChatEnabled } from '@/lib/hooks/use-server-info' -import { useTabbyAnswer } from '@/lib/hooks/use-tabby-answer' -import fetcher from '@/lib/tabby/fetcher' -import { - AnswerEngineExtraContext, - AnswerRequest, - AnswerResponse, - ArrayElementType, - RelevantCodeContext -} from '@/lib/types' +import { AnswerEngineExtraContext, RelevantCodeContext } from '@/lib/types' import { cn, formatLineHashForCodeBrowser } from '@/lib/utils' import { Button } from '@/components/ui/button' import { CodeBlock } from '@/components/ui/codeblock' @@ -50,7 +41,7 @@ import { IconChevronRight, IconLayers, IconPlus, - IconRefresh, + // IconRefresh, IconSparkles, IconSpinner, IconStop @@ -75,12 +66,27 @@ import UserPanel from '@/components/user-panel' import './search.css' -import { compact, isNil, pick } from 'lodash-es' +import { compact, isEmpty, isNil, pick } from 'lodash-es' import { ImperativePanelHandle } from 'react-resizable-panels' +import slugify from 'slugify' import { Context } from 'tabby-chat-panel/index' import { useQuery } from 'urql' -import { RepositoryListQuery } from '@/lib/gql/generates/graphql' +import { graphql } from '@/lib/gql/generates' +import { + CodeQueryInput, + InputMaybe, + Maybe, + Message, + MessageAttachmentCode, + MessageAttachmentDoc, + MessageCodeSearchHit, + MessageDocSearchHit, + RepositoryListQuery, + Role +} from '@/lib/gql/generates/graphql' +import useRouterStuff from '@/lib/hooks/use-router-stuff' +import { useThreadRun } from '@/lib/hooks/use-thread-run' import { repositoryListQuery } from '@/lib/tabby/query' import { Tooltip, @@ -90,23 +96,34 @@ import { import { CodeReferences } from '@/components/chat/question-answer' import { DevPanel } from './dev-panel' - -interface Source { - title: string - link: string - snippet: string +import { MessagesSkeleton } from './messages-skeleton' + +type ConversationMessage = Omit< + Message, + '__typename' | 'updatedAt' | 'createdAt' | 'attachment' | 'threadId' +> & { + threadId?: string + threadRelevantQuestions?: Maybe + // isLoading?: boolean + error?: string + attachment?: { + code: Array + doc: Array + } } -type ConversationMessage = Message & { - relevant_code?: AnswerResponse['relevant_code'] - relevant_documents?: AnswerResponse['relevant_documents'] - relevant_questions?: string[] - code_query?: AnswerRequest['code_query'] - isLoading?: boolean - error?: string +// for rendering, including scores +type AttachmentCodeItem = MessageAttachmentCode & { + extra?: { scores?: MessageCodeSearchHit['scores'] } +} +// for rendering, including score +type AttachmentDocItem = MessageAttachmentDoc & { + extra?: { score?: MessageDocSearchHit['score'] } } type SearchContextValue = { + // flag for initialize the pathname + isPathnameInitialized: boolean isLoading: boolean onRegenerateResponse: (id: string) => void onSubmitSearch: (question: string) => void @@ -120,79 +137,166 @@ export const SearchContext = createContext( {} as SearchContextValue ) -const tabbyFetcher = ((url: string, init?: RequestInit) => { - return fetcher(url, { - ...init, - responseFormatter(response) { - return response - }, - errorHandler(response) { - throw new Error(response ? String(response.status) : 'Fail to fetch') - } - }) -}) as typeof fetch - const SOURCE_CARD_STYLE = { compress: 5.3, expand: 6.3 } +const listThreadMessages = graphql(/* GraphQL */ ` + query ListThreadMessages( + $threadId: ID! + $after: String + $before: String + $first: Int + $last: Int + ) { + threadMessages( + threadId: $threadId + after: $after + before: $before + first: $first + last: $last + ) { + edges { + node { + id + threadId + role + content + attachment { + code { + gitUrl + filepath + language + content + startLine + } + doc { + title + link + content + } + } + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`) + export function Search() { + const { updateUrlComponents, pathname, searchParams } = useRouterStuff() + const [activePathname, setActivePathname] = useState() + const [isPathnameInitialized, setIsPathnameInitialized] = useState(false) const isChatEnabled = useIsChatEnabled() const [searchFlag] = useEnableSearch() - const [conversation, setConversation] = useState([]) - const [showStop, setShowStop] = useState(true) - const [container, setContainer] = useState(null) - const [title, setTitle] = useState('') + const [messages, setMessages] = useState([]) + const [stopButtonVisible, setStopButtonVisible] = useState(true) const [isReady, setIsReady] = useState(false) const [extraContext, setExtraContext] = useState({}) - const [currentLoadindId, setCurrentLoadingId] = useState('') + const [currentAssistantMessageId, setCurrentAssistantMessageId] = + useState('') const contentContainerRef = useRef(null) const [showSearchInput, setShowSearchInput] = useState(false) const [isShowDemoBanner] = useShowDemoBanner() const router = useRouter() - const initCheckRef = useRef(false) + const initializing = useRef(false) const { theme } = useCurrentTheme() const [devPanelOpen, setDevPanelOpen] = useState(false) - const [conversationIdForDev, setConversationIdForDev] = useState< - string | undefined - >() + const [messageIdForDev, setMessageIdForDev] = useState() const devPanelRef = useRef(null) const [devPanelSize, setDevPanelSize] = useState(45) const prevDevPanelSize = useRef(devPanelSize) + const threadId = useMemo(() => { + const regex = /^\/search\/(.*)/ + if (!activePathname) return undefined + + return activePathname.match(regex)?.[1]?.split('-').pop() + }, [activePathname]) + const [{ data }] = useQuery({ query: repositoryListQuery }) const repositoryList = data?.repositoryList - const { triggerRequest, isLoading, error, answer, stop } = useTabbyAnswer({ - fetcher: tabbyFetcher + const [afterCursor, setAfterCursor] = useState() + const [{ data: threadMessages, fetching: fetchingMessages }] = useQuery({ + query: listThreadMessages, + variables: { + threadId: threadId as string, + first: 20, + after: afterCursor + }, + pause: !threadId || isReady + }) + + // todo scroll and setAfterCursor + useEffect(() => { + if (threadMessages?.threadMessages?.edges?.length) { + const messages = threadMessages.threadMessages.edges + .map(o => o.node) + .slice() + setMessages(messages) + setIsReady(true) + } + }, [threadMessages]) + + const onThreadCreated = (threadId: string) => { + const title = messages?.[0]?.content + const slug = slugify(title, { + lower: true + }) + .split('-') + .slice(0, 8) + .join('-') + + if (slug) { + document.title = slug + } else { + document.title = title + } + + const slugWithThreadId = compact([slug, threadId]).join('-') + + updateUrlComponents({ + pathname: `/search/${slugWithThreadId}`, + searchParams: { + del: ['q'] + } + }) + } + + const { triggerRequest, isLoading, error, answer, stop } = useThreadRun({ + threadId, + onThreadCreated }) const isLoadingRef = useLatest(isLoading) - const currentConversationForDev = useMemo(() => { - return conversation.find(item => item.id === conversationIdForDev) - }, [conversationIdForDev, conversation]) + const currentMessageForDev = useMemo(() => { + return messages.find(item => item.id === messageIdForDev) + }, [messageIdForDev, messages]) const valueForDev = useMemo(() => { - if (currentConversationForDev) { - return pick( - currentConversationForDev, - 'relevant_documents', - 'relevant_code' - ) + if (currentMessageForDev) { + return pick(currentMessageForDev?.attachment, 'doc', 'code') } return { - answers: conversation - .filter(o => o.role === 'assistant') - .map(o => pick(o, 'relevant_documents', 'relevant_code')) + answers: messages + .filter(o => o.role === Role.Assistant) + .map(o => pick(o, 'doc', 'code')) } }, [ - conversationIdForDev, - currentConversationForDev?.relevant_documents, - currentConversationForDev?.relevant_code + messageIdForDev, + currentMessageForDev?.attachment?.code, + currentMessageForDev?.attachment?.doc ]) const onPanelLayout = (sizes: number[]) => { @@ -201,45 +305,60 @@ export function Search() { } } + // for synchronizing the active pathname + useEffect(() => { + // prevActivePath.current = activePath + setActivePathname(pathname) + + if (!isPathnameInitialized) { + setIsPathnameInitialized(true) + } + }, [pathname]) + // Check sessionStorage for initial message or most recent conversation useEffect(() => { - if (initCheckRef.current) return + const init = () => { + if (initializing.current) return - initCheckRef.current = true + initializing.current = true - const initialMessage = sessionStorage.getItem( - SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG - ) - const initialExtraContextStr = sessionStorage.getItem( - SESSION_STORAGE_KEY.SEARCH_INITIAL_EXTRA_CONTEXT - ) - const initialExtraInfo = initialExtraContextStr - ? JSON.parse(initialExtraContextStr) - : undefined - if (initialMessage) { - sessionStorage.removeItem(SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG) - sessionStorage.removeItem( + // initial UserMessage from home page + const initialMessage = sessionStorage.getItem( + SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG + ) + // initial extra context from home page + const initialExtraContextStr = sessionStorage.getItem( SESSION_STORAGE_KEY.SEARCH_INITIAL_EXTRA_CONTEXT ) - setIsReady(true) - setExtraContext(p => ({ - ...p, - repository: initialExtraInfo?.repository - })) - // FIXME(jueliang) just use the value in context - onSubmitSearch(initialMessage, { - repository: initialExtraInfo?.repository - }) - return - } + const initialExtraInfo = initialExtraContextStr + ? JSON.parse(initialExtraContextStr) + : undefined + + if (initialMessage) { + sessionStorage.removeItem(SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG) + sessionStorage.removeItem( + SESSION_STORAGE_KEY.SEARCH_INITIAL_EXTRA_CONTEXT + ) + setIsReady(true) + setExtraContext(p => ({ + ...p, + repository: initialExtraInfo?.repository + })) + onSubmitSearch(initialMessage, { + repository: initialExtraInfo?.repository + }) + return + } - router.replace('/') - }, []) + if (!threadId) { + router.replace('/') + } + } - // Set page title to the value of the first quesiton - useEffect(() => { - if (title) document.title = title - }, [title]) + if (isPathnameInitialized) { + init() + } + }, [isPathnameInitialized]) // Display the input field with a delayed animatio useEffect(() => { @@ -250,57 +369,88 @@ export function Search() { } }, [isReady]) - // Initialize the reference to the ScrollArea used for scrolling to the bottom + // Handling the stream response from useThreadRun useEffect(() => { - setContainer( - contentContainerRef?.current?.children[1] as HTMLDivElement | null - ) - }, [contentContainerRef?.current]) + if (!answer) return - // Handling the stream response from useTabbyAnswer - useEffect(() => { - const newConversation = [...conversation] - const currentAnswer = newConversation.find( - item => item.id === currentLoadindId + let newMessages = [...messages] + const currentAssistantMessageIndex = newMessages.findIndex( + item => item.id === currentAssistantMessageId ) - if (!currentAnswer) return + if (currentAssistantMessageIndex <= 0) return + + // updateUserMessageId + // if (threadRun?.threadUserMessageCreated) { + // const currentUserMessage = newMessages[currentAssistantMessageIndex - 1] + // newMessages = getMessagesWithNewMessageId( + // newMessages, + // currentUserMessage.id, + // threadRun.threadUserMessageCreated + // ) + // } + + const currentAssistantMessage = newMessages[currentAssistantMessageIndex] + + // updateAssistantMessageId + // if (threadRun?.threadAssistantMessageCreated) { + // currentAssistantMessage.id = threadRun.threadAssistantMessageCreated + // setCurrentAssistantMessageId(threadRun?.threadAssistantMessageCreated) + // } + + currentAssistantMessage.content = + answer?.threadAssistantMessageContentDelta || '' + currentAssistantMessage.attachment = { + // format the attachments + code: + answer?.threadAssistantMessageAttachmentsCode?.map(hit => ({ + ...hit.code, + extra: { + scores: hit.scores + } + })) ?? [], + doc: + answer?.threadAssistantMessageAttachmentsDoc?.map(hit => ({ + ...hit.doc, + extra: { + score: hit.score + } + })) ?? [] + } + currentAssistantMessage.threadRelevantQuestions = + answer?.threadRelevantQuestions - currentAnswer.content = answer?.answer_delta || '' - currentAnswer.relevant_code = answer?.relevant_code - currentAnswer.relevant_documents = answer?.relevant_documents - currentAnswer.relevant_questions = answer?.relevant_questions - currentAnswer.isLoading = isLoading - setConversation(newConversation) + setMessages(newMessages) }, [isLoading, answer]) - // Handling the error response from useTabbyAnswer + // Handling the error response from useThreadRun useEffect(() => { if (error) { - const newConversation = [...conversation] + const newConversation = [...messages] const currentAnswer = newConversation.find( - item => item.id === currentLoadindId + item => item.id === currentAssistantMessageId ) if (currentAnswer) { currentAnswer.error = error.message === '401' ? 'Unauthorized' : 'Fail to fetch' - currentAnswer.isLoading = false } } }, [error]) // Delay showing the stop button - let showStopTimeoutId: number + const showStopTimeoutId = useRef() + useEffect(() => { if (isLoadingRef.current) { - showStopTimeoutId = window.setTimeout(() => { + showStopTimeoutId.current = window.setTimeout(() => { if (!isLoadingRef.current) return - setShowStop(true) + setStopButtonVisible(true) // Scroll to the bottom + const container = contentContainerRef?.current?.children?.[1] if (container) { const isLastAnswerLoading = - currentLoadindId === conversation[conversation.length - 1].id + currentAssistantMessageId === messages[messages.length - 1].id if (isLastAnswerLoading) { container.scrollTo({ top: container.scrollHeight, @@ -312,21 +462,14 @@ export function Search() { } if (!isLoadingRef.current) { - setShowStop(false) + setStopButtonVisible(false) } return () => { - window.clearTimeout(showStopTimeoutId) + window.clearTimeout(showStopTimeoutId.current) } }, [isLoading]) - // Stop stream before closing the page - useEffect(() => { - return () => { - if (isLoadingRef.current) stop() - } - }, []) - useEffect(() => { if (devPanelOpen) { devPanelRef.current?.expand() @@ -337,89 +480,71 @@ export function Search() { }, [devPanelOpen]) const onSubmitSearch = (question: string, ctx?: AnswerEngineExtraContext) => { - const previousMessages = conversation.map(message => ({ - role: message.role, - id: message.id, - content: message.content - })) - const previousUserId = previousMessages.length > 0 && previousMessages[0].id const newAssistantId = nanoid() const newUserMessage: ConversationMessage = { - id: previousUserId || nanoid(), - role: 'user', + id: nanoid(), + role: Role.User, content: question } const newAssistantMessage: ConversationMessage = { id: newAssistantId, - role: 'assistant', - content: '', - isLoading: true + role: Role.Assistant, + content: '' } const _repository = ctx?.repository || extraContext?.repository - const code_query: AnswerRequest['code_query'] = _repository - ? { git_url: _repository.gitUrl, content: '' } - : undefined - const answerRequest: AnswerRequest = { - messages: [...previousMessages, newUserMessage], - doc_query: true, - generate_relevant_questions: true, - collect_relevant_code_using_user_message: true, - code_query - } + const codeQuery: InputMaybe = _repository + ? { gitUrl: _repository.gitUrl, content: question } + : null - setCurrentLoadingId(newAssistantId) - setConversation( - [...conversation].concat([newUserMessage, newAssistantMessage]) - ) - triggerRequest(answerRequest) + setCurrentAssistantMessageId(newAssistantId) + setMessages([...messages].concat([newUserMessage, newAssistantMessage])) - // Update HTML page title - if (!title) setTitle(question) + triggerRequest( + { + content: question + }, + { + generateRelevantQuestions: true, + codeQuery, + docQuery: { content: question } + } + ) } + // FIXME const onRegenerateResponse = ( id: string, conversationData?: ConversationMessage[] ) => { - const data = conversationData || conversation + const data = conversationData || messages const targetAnswerIdx = data.findIndex(item => item.id === id) if (targetAnswerIdx < 1) return const targetQuestionIdx = targetAnswerIdx - 1 const targetQuestion = data[targetQuestionIdx] - const previousMessages = data.slice(0, targetQuestionIdx).map(message => ({ - role: message.role, - id: message.id, - content: message.content, - code_query: message.code_query - })) - const newUserMessage: ConversationMessage = { - role: 'user', - id: targetQuestion.id, - content: targetQuestion.content - } - const answerRequest: AnswerRequest = { - messages: [...previousMessages, newUserMessage], - code_query: extraContext?.repository - ? { git_url: extraContext.repository.gitUrl, content: '' } - : undefined, - doc_query: true, - generate_relevant_questions: true, - collect_relevant_code_using_user_message: true - } - - const newConversation = [...data] - let newTargetAnswer = newConversation[targetAnswerIdx] + const newMessages = [...data] + let newTargetAnswer = newMessages[targetAnswerIdx] newTargetAnswer.content = '' - newTargetAnswer.relevant_code = undefined - newTargetAnswer.relevant_documents = undefined + newTargetAnswer.attachment = { + doc: [], + code: [] + } newTargetAnswer.error = undefined - newTargetAnswer.isLoading = true + // newTargetAnswer.isLoading = true - setCurrentLoadingId(newTargetAnswer.id) - setConversation(newConversation) - triggerRequest(answerRequest) + setCurrentAssistantMessageId(newTargetAnswer.id) + setMessages(newMessages) + triggerRequest( + { + content: targetQuestion.content + }, + { + generateRelevantQuestions: true, + docQuery: { content: targetQuestion.content } + // FIXME docQuery + } + ) } const onToggleFullScreen = (fullScreen: boolean) => { @@ -434,6 +559,15 @@ export function Search() { prevDevPanelSize.current = devPanelSize } + if (!isReady && fetchingMessages) { + return ( +
+ + +
+ ) + } + if (!searchFlag.value || !isChatEnabled || !isReady) { return <> } @@ -451,7 +585,8 @@ export function Search() { extraRequestContext: extraContext, repositoryList, setDevPanelOpen, - setConversationIdForDev + setConversationIdForDev: setMessageIdForDev, + isPathnameInitialized }} >
@@ -462,13 +597,23 @@ export function Search() {
+ {!!threadId && ( + + )} @@ -482,8 +627,8 @@ export function Search() {
- {conversation.map((item, idx) => { - if (item.role === 'user') { + {messages.map((item, idx) => { + if (item.role === Role.User) { return (
{idx !== 0 && } @@ -496,14 +641,16 @@ export function Search() {
) } - if (item.role === 'assistant') { + if (item.role === Role.Assistant) { + const isLastAssistantMessage = + idx === messages.length - 1 return (
) @@ -514,20 +661,25 @@ export function Search() {
- {container && ( - - )} + )} + container={ + contentContainerRef.current?.children?.[1] as HTMLDivElement + } + offset={100} + // On mobile browsers(Chrome & Safari) in dark mode, using `background: hsl(var(--background))` + // result in `rgba(0, 0, 0, 0)`. To prevent this, explicitly set --background + style={ + theme === 'dark' + ? ({ '--background': '0 0% 12%' } as CSSProperties) + : {} + } + />
- )} + )} */}
)}
{/* Related questions */} {showRelatedQuestion && - !answer.isLoading && - answer.relevant_questions && - answer.relevant_questions.length > 0 && ( + !isLoading && + answer.threadRelevantQuestions && + answer.threadRelevantQuestions.length > 0 && (

Suggestions

- {answer.relevant_questions?.map((related, index) => ( + {answer.threadRelevantQuestions?.map((related, index) => (
+ source: AttachmentDocItem showMore: boolean showDevTooltip?: boolean }) { const { setDevPanelOpen, setConversationIdForDev } = useContext(SearchContext) - const { hostname } = new URL(source.doc.link) + const { hostname } = new URL(source.link) const [devTooltipOpen, setDevTooltipOpen] = useState(false) const onOpenChange = (v: boolean) => { @@ -942,12 +1097,12 @@ function SourceCard({ : `${SOURCE_CARD_STYLE.compress}rem`, transition: 'all 0.25s ease-out' }} - onClick={() => window.open(source.doc.link)} + onClick={() => window.open(source.link)} >

- {source.doc.title} + {source.title}

- {normalizedText(source.doc.snippet)} + {normalizedText(source.content)}

@@ -977,7 +1132,7 @@ function SourceCard({ className="cursor-pointer p-2" onClick={onTootipClick} > -

Score: {source.score}

+

Score: {source?.extra?.score ?? '-'}

) @@ -985,38 +1140,34 @@ function SourceCard({ type RelevantDocItem = { type: 'doc' - data: ArrayElementType + data: AttachmentDocItem } type RelevantCodeItem = { type: 'code' - data: ArrayElementType + data: AttachmentCodeItem } -type RelevantSources = Array +type MessageAttachments = Array function MessageMarkdown({ message, headline = false, relevantDocuments, - relevantCode, - onRelevantCodeClick + relevantCode }: { message: string headline?: boolean - relevantDocuments?: AnswerResponse['relevant_documents'] - relevantCode?: AnswerResponse['relevant_code'] - onRelevantCodeClick?: ( - code: ArrayElementType - ) => void + relevantDocuments?: Array + relevantCode?: Array }) { - const relevantSources: RelevantSources = useMemo(() => { - const docs: RelevantSources = + const messageAttachments: MessageAttachments = useMemo(() => { + const docs: MessageAttachments = relevantDocuments?.map(item => ({ type: 'doc', data: item })) ?? [] - const code: RelevantSources = + const code: MessageAttachments = relevantCode?.map(item => ({ type: 'code', data: item @@ -1037,7 +1188,7 @@ function MessageMarkdown({ ? parseInt(citationNumberMatch[0], 10) : null const citationSource = !isNil(citationIndex) - ? relevantSources?.[citationIndex - 1] + ? messageAttachments?.[citationIndex - 1] : undefined const citationType = citationSource?.type const showcitation = citationSource && !isNil(citationIndex) @@ -1048,12 +1199,12 @@ function MessageMarkdown({ {showcitation && ( <> {citationType === 'doc' ? ( - ) : citationType === 'code' ? ( - @@ -1150,21 +1301,21 @@ function MessageMarkdown({ ) } -function RelevantDocumentHoverCard({ +function RelevantDocumentBadge({ relevantDocument, citationIndex }: { - relevantDocument: ArrayElementType + relevantDocument: MessageAttachmentDoc citationIndex: number }) { - const sourceUrl = relevantDocument ? new URL(relevantDocument.doc.link) : null + const sourceUrl = relevantDocument ? new URL(relevantDocument.link) : null return ( window.open(relevantDocument.doc.link)} + onClick={() => window.open(relevantDocument.link)} > {citationIndex} @@ -1180,12 +1331,12 @@ function RelevantDocumentHoverCard({

window.open(relevantDocument.doc.link)} + onClick={() => window.open(relevantDocument.link)} > - {relevantDocument.doc.title} + {relevantDocument.title}

- {normalizedText(relevantDocument.doc.snippet)} + {normalizedText(relevantDocument.content)}

@@ -1193,11 +1344,11 @@ function RelevantDocumentHoverCard({ ) } -function RelevantCodeHoverCard({ +function RelevantCodeBadge({ relevantCode, citationIndex }: { - relevantCode: ArrayElementType + relevantCode: MessageAttachmentCode citationIndex: number }) { const { @@ -1298,45 +1449,3 @@ function ErrorMessageBlock({ error = 'Fail to fetch' }: { error?: string }) { ) } - -// function ContextItem({ -// context, -// clickable = true -// }: { -// context: Context -// clickable?: boolean -// }) { -// const { onNavigateToContext } = React.useContext(ChatContext) -// const isMultiLine = -// !isNil(context.range?.start) && -// !isNil(context.range?.end) && -// context.range.start < context.range.end -// const pathSegments = context.filepath.split('/') -// const fileName = pathSegments[pathSegments.length - 1] -// const path = pathSegments.slice(0, pathSegments.length - 1).join('/') -// return ( -//
clickable && onNavigateToContext?.(context)} -// > -//
-// -//
-// {fileName} -// {context.range?.start && ( -// -// :{context.range.start} -// -// )} -// {isMultiLine && ( -// -{context.range.end} -// )} -// {path} -//
-//
-//
-// ) -// } diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index bd5ee3e4f302..0fb63a671ef2 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -620,15 +620,15 @@ function ContextItem({
rrf: - {scores?.rrf} + {scores?.rrf ?? '-'}
bm25: - {scores?.bm25} + {scores?.bm25 ?? '-'}
embedding: - {scores?.embedding} + {scores?.embedding ?? '-'}
diff --git a/ee/tabby-ui/lib/hooks/use-thread-run.ts b/ee/tabby-ui/lib/hooks/use-thread-run.ts new file mode 100644 index 000000000000..f6076b21698e --- /dev/null +++ b/ee/tabby-ui/lib/hooks/use-thread-run.ts @@ -0,0 +1,305 @@ +import React from 'react' +import { pickBy } from 'lodash-es' +import { OperationContext, useSubscription } from 'urql' + +import { graphql } from '@/lib/gql/generates' + +import { + CreateMessageInput, + ThreadRunItem, + ThreadRunOptionsInput +} from '../gql/generates/graphql' +import { useDebounceCallback } from './use-debounce' + +interface UseThreadRunOptions { + onError?: (err: Error) => void + threadId?: string + headers?: Record | Headers + onThreadCreated?: (threadId: string) => void +} + +const CreateThreadAndRunSubscription = graphql(/* GraphQL */ ` + subscription CreateThreadAndRun($input: CreateThreadAndRunInput!) { + createThreadAndRun(input: $input) { + threadCreated + threadUserMessageCreated + threadAssistantMessageCreated + threadRelevantQuestions + threadAssistantMessageAttachmentsCode { + code { + gitUrl + filepath + language + content + startLine + } + scores { + rrf + bm25 + embedding + } + } + threadAssistantMessageAttachmentsDoc { + doc { + title + link + content + } + score + } + threadAssistantMessageContentDelta + threadAssistantMessageCompleted + } + } +`) + +const CreateThreadRunSubscription = graphql(/* GraphQL */ ` + subscription CreateThreadRun($input: CreateThreadRunInput!) { + createThreadRun(input: $input) { + threadCreated + threadUserMessageCreated + threadAssistantMessageCreated + threadRelevantQuestions + threadAssistantMessageAttachmentsCode { + code { + gitUrl + filepath + language + content + startLine + } + scores { + rrf + bm25 + embedding + } + } + threadAssistantMessageAttachmentsDoc { + doc { + title + link + content + } + score + } + threadAssistantMessageContentDelta + threadAssistantMessageCompleted + } + } +`) + +export function useThreadRun({ + onError, + threadId: propsThreadId, + headers, + onThreadCreated +}: UseThreadRunOptions) { + const [threadId, setThreadId] = React.useState( + propsThreadId + ) + + const [pause, setPause] = React.useState(true) + const [followupPause, setFollowupPause] = React.useState(true) + const [createMessageInput, setCreateMessageInput] = + React.useState(null) + const [threadRunOptions, setThreadRunOptions] = React.useState< + ThreadRunOptionsInput | undefined + >() + const [isLoading, setIsLoading] = React.useState(false) + const [threadRunItem, setThreadRunItem] = React.useState< + ThreadRunItem | undefined + >() + const [error, setError] = React.useState() + const operationContext: Partial = React.useMemo(() => { + if (headers) { + return { + fetchOptions: { + headers + } + } + } + return {} + }, [headers]) + + const combineThreadRunData = ( + existingData: ThreadRunItem | undefined, + data: ThreadRunItem + ): ThreadRunItem => { + if (!data) return data + // new thread created + if (data.threadCreated) return data + // new userMessage created + if ( + existingData?.threadAssistantMessageCreated && + data.threadUserMessageCreated + ) + return data + + return { + ...existingData, + ...pickBy(data, v => v !== null), + threadAssistantMessageContentDelta: `${ + existingData?.threadAssistantMessageContentDelta ?? '' + }${data?.threadAssistantMessageContentDelta ?? ''}` + } + } + + const debouncedStop = useDebounceCallback( + (silent?: boolean) => { + setPause(true) + setFollowupPause(true) + setIsLoading(false) + if (!silent && !propsThreadId && threadId) { + onThreadCreated?.(threadId) + } + }, + 300, + { + leading: false, + onUnmount(debounced) { + if (isLoading) { + debounced(true) + } + debounced.flush() + } + } + ) + + const stop = (silent?: boolean) => debouncedStop.run(silent) + + const [createThreadAndRunResult] = useSubscription( + { + query: CreateThreadAndRunSubscription, + pause, + variables: { + input: { + thread: { + userMessage: createMessageInput as CreateMessageInput + }, + options: threadRunOptions + } + }, + context: operationContext + }, + (prevData, data) => { + return { + ...data, + createThreadAndRun: combineThreadRunData( + prevData?.createThreadAndRun, + data.createThreadAndRun + ) + } + } + ) + + const [createThreadRunResult] = useSubscription( + { + query: CreateThreadRunSubscription, + pause: followupPause, + variables: { + input: { + threadId: threadId as string, + additionalUserMessage: createMessageInput as CreateMessageInput, + options: threadRunOptions + } + }, + context: operationContext + }, + (prevData, data) => { + return { + ...data, + createThreadRun: combineThreadRunData( + prevData?.createThreadRun, + data.createThreadRun + ) + } + } + ) + + React.useEffect(() => { + if (propsThreadId && propsThreadId !== threadId) { + setThreadId(propsThreadId) + } + }, [propsThreadId]) + + // createThreadAndRun + React.useEffect(() => { + if ( + createThreadAndRunResult?.data?.createThreadAndRun + ?.threadAssistantMessageCompleted + ) { + stop() + } + if ( + createThreadAndRunResult.fetching || + !createThreadAndRunResult?.operation + ) + return + // error handling + if (createThreadAndRunResult?.error) { + setError(createThreadAndRunResult?.error) + stop() + return + } + // save the threadId + if (createThreadAndRunResult?.data?.createThreadAndRun?.threadCreated) { + setThreadId( + createThreadAndRunResult?.data?.createThreadAndRun?.threadCreated + ) + } + if (createThreadAndRunResult?.data?.createThreadAndRun) { + setThreadRunItem(createThreadAndRunResult?.data?.createThreadAndRun) + } + }, [createThreadAndRunResult]) + + // createThreadRun + React.useEffect(() => { + if ( + createThreadRunResult?.data?.createThreadRun + ?.threadAssistantMessageCompleted + ) { + stop() + } + + if (createThreadRunResult?.fetching || !createThreadRunResult?.operation) + return + + // error handling + if (createThreadRunResult?.error) { + setError(createThreadRunResult?.error) + stop() + return + } + + if (createThreadRunResult?.data?.createThreadRun) { + setThreadRunItem(createThreadRunResult?.data?.createThreadRun) + } + }, [createThreadRunResult]) + + const triggerRequest = async ( + userMessage: CreateMessageInput, + options?: ThreadRunOptionsInput + ) => { + setIsLoading(true) + setError(undefined) + setThreadRunItem(undefined) + + setCreateMessageInput(userMessage) + setThreadRunOptions(options) + + if (threadId) { + setFollowupPause(false) + } else { + setPause(false) + } + } + + return { + isLoading, + answer: threadRunItem, + error, + setError, + triggerRequest, + stop + } +} diff --git a/ee/tabby-ui/lib/tabby/gql.ts b/ee/tabby-ui/lib/tabby/gql.ts index 76decd49b587..03e389a23a6e 100644 --- a/ee/tabby-ui/lib/tabby/gql.ts +++ b/ee/tabby-ui/lib/tabby/gql.ts @@ -2,6 +2,7 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core' import { authExchange } from '@urql/exchange-auth' import { cacheExchange } from '@urql/exchange-graphcache' import { relayPagination } from '@urql/exchange-graphcache/extras' +import { createClient as createWSClient } from 'graphql-ws' import { jwtDecode } from 'jwt-decode' import { isNil } from 'lodash-es' import { FieldValues, UseFormReturn } from 'react-hook-form' @@ -12,6 +13,7 @@ import { errorExchange, fetchExchange, OperationResult, + subscriptionExchange, useMutation as useUrqlMutation } from 'urql' @@ -108,7 +110,11 @@ const client = new Client({ GrepTextOrBase64: () => null, GrepSubMatch: () => null, Repository: (data: any) => (data ? `${data.kind}_${data.id}` : null), - GitReference: () => null + GitReference: () => null, + MessageAttachment: () => null, + MessageAttachmentCode: () => null, + MessageAttachmentDoc: () => null, + NetworkSetting: () => null }, resolvers: { Query: { @@ -342,7 +348,27 @@ const client = new Client({ } } }), - fetchExchange + fetchExchange, + subscriptionExchange({ + forwardSubscription(request, operation) { + const authorization = + // @ts-ignore + operation.context.fetchOptions?.headers?.Authorization ?? '' + const wsClient = createWSClient({ + url: '/subscriptions', + connectionParams: { + authorization + } + }) + const input = { ...request, query: request.query || '' } + return { + subscribe(sink) { + const unsubscribe = wsClient.subscribe(input, sink) + return { unsubscribe } + } + } + } + }) ] }) diff --git a/ee/tabby-ui/package.json b/ee/tabby-ui/package.json index 35984c1a21ab..1073f7c9e9d1 100644 --- a/ee/tabby-ui/package.json +++ b/ee/tabby-ui/package.json @@ -68,6 +68,7 @@ "eventsource-parser": "^1.1.2", "focus-trap-react": "^10.1.1", "graphql": "^16.8.1", + "graphql-ws": "^5.16.0", "humanize-duration": "^3.31.0", "jwt-decode": "^4.0.0", "lodash-es": "^4.17.21", @@ -104,6 +105,7 @@ "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", "seedrandom": "^3.0.5", + "slugify": "^1.6.6", "sonner": "^1.3.1", "swr": "^2.2.4", "tabby-chat-panel": "workspace:*", diff --git a/ee/tabby-webserver/development/Caddyfile b/ee/tabby-webserver/development/Caddyfile index 517994f3ffed..9aac722cd3d3 100644 --- a/ee/tabby-webserver/development/Caddyfile +++ b/ee/tabby-webserver/development/Caddyfile @@ -5,6 +5,7 @@ @backend { path /graphql path /graphiql + path /subscriptions path /v1/* path /v1beta/* diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb6d5f236e6b..8c5e48d23d6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: graphql: specifier: ^16.8.1 version: 16.8.1 + graphql-ws: + specifier: ^5.16.0 + version: 5.16.0(graphql@16.8.1) humanize-duration: specifier: ^3.31.0 version: 3.31.0 @@ -645,6 +648,9 @@ importers: seedrandom: specifier: ^3.0.5 version: 3.0.5 + slugify: + specifier: ^1.6.6 + version: 1.6.6 sonner: specifier: ^1.3.1 version: 1.3.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -6246,8 +6252,8 @@ packages: peerDependencies: graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - graphql-ws@5.14.2: - resolution: {integrity: sha512-LycmCwhZ+Op2GlHz4BZDsUYHKRiiUz+3r9wbhBATMETNlORQJAaFlAgTFoeRh6xQoQegwYwIylVD1Qns9/DA3w==} + graphql-ws@5.16.0: + resolution: {integrity: sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==} engines: {node: '>=10'} peerDependencies: graphql: '>=0.11 <=16' @@ -9061,6 +9067,10 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -10541,7 +10551,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -10930,7 +10940,7 @@ snapshots: '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.23.5 '@babel/types': 7.23.5 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -11462,7 +11472,7 @@ snapshots: '@eslint/eslintrc@2.1.2': dependencies: ajv: 6.12.6 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 espree: 9.6.1 globals: 13.22.0 ignore: 5.3.1 @@ -11739,7 +11749,7 @@ snapshots: '@graphql-tools/utils': 10.0.8(graphql@16.8.1) '@types/ws': 8.5.9 graphql: 16.8.1 - graphql-ws: 5.14.2(graphql@16.8.1) + graphql-ws: 5.16.0(graphql@16.8.1) isomorphic-ws: 5.0.0(ws@8.14.2) tslib: 2.6.2 ws: 8.14.2 @@ -11872,7 +11882,7 @@ snapshots: '@types/json-stable-stringify': 1.0.36 '@whatwg-node/fetch': 0.9.14 chalk: 4.1.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 dotenv: 16.3.1 graphql: 16.8.1 graphql-request: 6.1.0(encoding@0.1.13)(graphql@16.8.1) @@ -11960,7 +11970,7 @@ snapshots: '@humanwhocodes/config-array@0.11.11': dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -13912,7 +13922,7 @@ snapshots: '@typescript-eslint/type-utils': 7.4.0(eslint@8.50.0)(typescript@5.2.2) '@typescript-eslint/utils': 7.4.0(eslint@8.50.0)(typescript@5.2.2) '@typescript-eslint/visitor-keys': 7.4.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 eslint: 8.50.0 graphemer: 1.4.0 ignore: 5.3.1 @@ -13929,7 +13939,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 eslint: 8.50.0 optionalDependencies: typescript: 5.2.2 @@ -14035,7 +14045,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.4.0(typescript@5.2.2) '@typescript-eslint/utils': 7.4.0(eslint@8.50.0)(typescript@5.2.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 eslint: 8.50.0 ts-api-utils: 1.3.0(typescript@5.2.2) optionalDependencies: @@ -14055,7 +14065,7 @@ snapshots: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 semver: 7.6.2 @@ -14114,7 +14124,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.4.0 '@typescript-eslint/visitor-keys': 7.4.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -14598,7 +14608,7 @@ snapshots: agent-base@7.1.0: dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -15631,6 +15641,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.3.4(supports-color@8.1.1): dependencies: ms: 2.1.2 @@ -16212,7 +16226,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.50.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.50.0): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 enhanced-resolve: 5.15.0 eslint: 8.50.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0(eslint@8.50.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.50.0)(typescript@5.2.2))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.28.1)(eslint@8.50.0))(eslint@8.50.0) @@ -16558,7 +16572,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -17231,7 +17245,7 @@ snapshots: graphql: 16.8.1 tslib: 2.6.2 - graphql-ws@5.14.2(graphql@16.8.1): + graphql-ws@5.16.0(graphql@16.8.1): dependencies: graphql: 16.8.1 @@ -17428,7 +17442,7 @@ snapshots: http-proxy-agent@7.0.0: dependencies: agent-base: 7.1.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -17449,7 +17463,7 @@ snapshots: https-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -18629,7 +18643,7 @@ snapshots: micromark@3.2.0: dependencies: '@types/debug': 4.1.9 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4 decode-named-character-reference: 1.0.2 micromark-core-commonmark: 1.1.0 micromark-factory-space: 1.1.0 @@ -20544,6 +20558,8 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + slugify@1.6.6: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4