diff --git a/ee/tabby-ui/app/page/components/header.tsx b/ee/tabby-ui/app/page/components/header.tsx new file mode 100644 index 000000000000..da2fe1f2cb73 --- /dev/null +++ b/ee/tabby-ui/app/page/components/header.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useContext, useState } from 'react' +import type { MouseEvent } from 'react' +import { useRouter } from 'next/navigation' +import { toast } from 'sonner' + +import { graphql } from '@/lib/gql/generates' +import { clearHomeScrollPosition } from '@/lib/stores/scroll-store' +import { useMutation } from '@/lib/tabby/gql' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Button, buttonVariants } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { + IconChevronLeft, + IconEdit, + IconMore, + IconPlus, + IconSpinner, + IconTrash +} from '@/components/ui/icons' +import { ClientOnly } from '@/components/client-only' +import { NotificationBox } from '@/components/notification-box' +import { ThemeToggle } from '@/components/theme-toggle' +import { MyAvatar } from '@/components/user-avatar' +import UserPanel from '@/components/user-panel' + +import { PageContext } from './page' + +const deleteThreadMutation = graphql(/* GraphQL */ ` + mutation DeleteThread($id: ID!) { + deleteThread(id: $id) + } +`) + +type HeaderProps = { + threadIdFromURL?: string + streamingDone?: boolean +} + +export function Header({ threadIdFromURL, streamingDone }: HeaderProps) { + const router = useRouter() + const { isThreadOwner, mode, setMode } = useContext(PageContext) + const isEditMode = mode === 'edit' + const [deleteAlertVisible, setDeleteAlertVisible] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + + const deleteThread = useMutation(deleteThreadMutation, { + onCompleted(data) { + if (data.deleteThread) { + router.replace('/') + } else { + toast.error('Failed to delete') + setIsDeleting(false) + } + }, + onError(err) { + toast.error(err?.message || 'Failed to delete') + setIsDeleting(false) + } + }) + + const handleDeleteThread = (e: MouseEvent) => { + e.preventDefault() + setIsDeleting(true) + deleteThread({ + id: threadIdFromURL! + }) + } + + const onNavigateToHomePage = (scroll?: boolean) => { + if (scroll) { + clearHomeScrollPosition() + } + router.push('/') + } + + return ( +
+
+ +
+
+ {isEditMode ? Editing : Draft Page} +
+
+ {!isEditMode ? ( + <> + + + + + + {streamingDone && threadIdFromURL && ( + onNavigateToHomePage(true)} + > + + Add new page + + )} + {streamingDone && threadIdFromURL && isThreadOwner && ( + + + + + Delete Page + + + + + Delete this thread + + Are you sure you want to delete this thread? This + operation is not revertible. + + + + Cancel + + {isDeleting && ( + + )} + Yes, delete it + + + + + )} + + + + + + ) : ( + <> + + + )} + + + + + { + clearHomeScrollPosition() + }} + > + + +
+
+ ) +} diff --git a/ee/tabby-ui/app/page/components/messages-skeleton.tsx b/ee/tabby-ui/app/page/components/messages-skeleton.tsx new file mode 100644 index 000000000000..346f10099d25 --- /dev/null +++ b/ee/tabby-ui/app/page/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/page/components/nav-bar.tsx b/ee/tabby-ui/app/page/components/nav-bar.tsx new file mode 100644 index 000000000000..1a47671ac8d2 --- /dev/null +++ b/ee/tabby-ui/app/page/components/nav-bar.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { compact } from 'lodash-es' + +import { useDebounceCallback } from '@/lib/hooks/use-debounce' + +import { ConversationPair } from './page' + +interface Props { + qaPairs: ConversationPair[] | undefined +} + +export const Navbar = ({ qaPairs }: Props) => { + const sections = useMemo(() => { + if (!qaPairs?.length) return [] + return compact(qaPairs.map(x => x.question)) + }, [qaPairs]) + + const [activeNavItem, setActiveNavItem] = useState() + const observer = useRef(null) + const updateActiveNavItem = useDebounceCallback((v: string) => { + setActiveNavItem(v) + }, 200) + + useEffect(() => { + const options = { + root: null, + rootMargin: '70px' + // threshold: 0.5, + } + + observer.current = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + updateActiveNavItem.run(entry.target.id) + break + } + } + }, options) + + const targets = document.querySelectorAll('.section-title') + targets.forEach(target => { + observer.current?.observe(target) + }) + + return () => { + observer.current?.disconnect() + } + }, []) + + return ( + + ) +} diff --git a/ee/tabby-ui/app/page/components/page.tsx b/ee/tabby-ui/app/page/components/page.tsx new file mode 100644 index 000000000000..9a1b0bceb744 --- /dev/null +++ b/ee/tabby-ui/app/page/components/page.tsx @@ -0,0 +1,1123 @@ +'use client' + +import { + createContext, + CSSProperties, + Dispatch, + Fragment, + SetStateAction, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import slugify from '@sindresorhus/slugify' +import { compact, pick, some, uniq, uniqBy } from 'lodash-es' +import { nanoid } from 'nanoid' +import { ImperativePanelHandle } from 'react-resizable-panels' +import { toast } from 'sonner' +import { useQuery } from 'urql' + +import { ERROR_CODE_NOT_FOUND, SLUG_TITLE_MAX_LENGTH } from '@/lib/constants' +import { useEnableDeveloperMode } from '@/lib/experiment-flags' +import { graphql } from '@/lib/gql/generates' +import { + CodeQueryInput, + ContextInfo, + DocQueryInput, + InputMaybe, + Maybe, + Message, + MessageAttachmentClientCode, + Role +} from '@/lib/gql/generates/graphql' +import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' +import { useCurrentTheme } from '@/lib/hooks/use-current-theme' +import { useDebounceValue } from '@/lib/hooks/use-debounce' +import { useLatest } from '@/lib/hooks/use-latest' +import { useMe } from '@/lib/hooks/use-me' +import { useSelectedModel } from '@/lib/hooks/use-models' +import useRouterStuff from '@/lib/hooks/use-router-stuff' +import { useThreadRun } from '@/lib/hooks/use-thread-run' +import { updateSelectedModel } from '@/lib/stores/chat-actions' +import { clearHomeScrollPosition } from '@/lib/stores/scroll-store' +import { useMutation } from '@/lib/tabby/gql' +import { + contextInfoQuery, + listThreadMessages, + listThreads, + setThreadPersistedMutation +} from '@/lib/tabby/query' +import { + AttachmentCodeItem, + AttachmentDocItem, + ExtendedCombinedError, + ThreadRunContexts +} from '@/lib/types' +import { + cn, + getMentionsFromText, + getThreadRunContextsFromMentions, + getTitleFromMessages +} from '@/lib/utils' +import { Button, buttonVariants } from '@/components/ui/button' +import { + IconCheck, + IconClock, + IconEdit, + IconEye, + IconFileSearch, + IconInfoCircled, + IconList, + IconListFilter, + IconPlus, + IconShare, + IconSheet, + IconStop +} from '@/components/ui/icons' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/components/ui/tooltip' +import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' +import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner' +import { MessageMarkdown } from '@/components/message-markdown' +import NotFoundPage from '@/components/not-found-page' +import TextAreaSearch from '@/components/textarea-search' +import { MyAvatar } from '@/components/user-avatar' + +import { Header } from './header' +import { MessagesSkeleton } from './messages-skeleton' +import { Navbar } from './nav-bar' +import { SectionContent } from './section-content' +import { SectionTitle } from './section-title' + +export type ConversationMessage = Omit< + Message, + '__typename' | 'updatedAt' | 'createdAt' | 'attachment' | 'threadId' +> & { + threadId?: string + threadRelevantQuestions?: Maybe + error?: string + attachment?: { + clientCode?: Maybe> | undefined + code: Maybe> | undefined + doc: Maybe> | undefined + } +} + +export type ConversationPair = { + question: ConversationMessage | null + answer: ConversationMessage | null +} + +type PageContextValue = { + mode: 'edit' | 'view' + setMode: Dispatch> + // flag for initialize the pathname + isPathnameInitialized: boolean + isLoading: boolean + onRegenerateResponse: (id: string) => void + onSubmitSearch: (question: string) => void + setDevPanelOpen: (v: boolean) => void + setConversationIdForDev: (v: string | undefined) => void + enableDeveloperMode: boolean + contextInfo: ContextInfo | undefined + fetchingContextInfo: boolean + onDeleteMessage: (id: string) => void + isThreadOwner: boolean + onUpdateMessage: ( + message: ConversationMessage + ) => Promise +} + +export const PageContext = createContext( + {} as PageContextValue +) + +export const SOURCE_CARD_STYLE = { + compress: 5.3, + expand: 6.3 +} + +const PAGE_SIZE = 30 + +const TEMP_MSG_ID_PREFIX = '_temp_msg_' +const tempNanoId = () => `${TEMP_MSG_ID_PREFIX}${nanoid()}` + +export function Page() { + const [{ data: meData }] = useMe() + const { updateUrlComponents, pathname } = useRouterStuff() + const [activePathname, setActivePathname] = useState() + const [isPathnameInitialized, setIsPathnameInitialized] = useState(false) + const [mode, setMode] = useState<'edit' | 'view'>('view') + + const [messages, setMessages] = useState([]) + const [stopButtonVisible, setStopButtonVisible] = useState(true) + const [isReady, setIsReady] = useState(false) + const [currentUserMessageId, setCurrentUserMessageId] = useState('') + const [currentAssistantMessageId, setCurrentAssistantMessageId] = + useState('') + const contentContainerRef = useRef(null) + const [showSearchInput, setShowSearchInput] = useState(false) + const [isShowDemoBanner] = useShowDemoBanner() + const initializing = useRef(false) + const { theme } = useCurrentTheme() + const [devPanelOpen, setDevPanelOpen] = useState(false) + const [messageIdForDev, setMessageIdForDev] = useState() + const devPanelRef = useRef(null) + const [devPanelSize, setDevPanelSize] = useState(45) + const prevDevPanelSize = useRef(devPanelSize) + const [enableDeveloperMode] = useEnableDeveloperMode() + const [threadId, setThreadId] = useState() + const threadIdFromURL = useMemo(() => { + const regex = /^\/page\/(.*)/ + if (!activePathname) return undefined + + return activePathname.match(regex)?.[1]?.split('-').pop() + }, [activePathname]) + + const updateThreadMessage = useMutation(updateThreadMessageMutation) + + const onUpdateMessage = async ( + message: ConversationMessage + ): Promise => { + const messageIndex = messages.findIndex(o => o.id === message.id) + if (messageIndex > -1 && threadId) { + // 1. call api + const result = await updateThreadMessage({ + input: { + threadId, + id: message.id, + content: message.content + } + }) + if (result?.data?.updateThreadMessage) { + // 2. set messages + await setMessages(prev => { + const newMessages = [...prev] + newMessages[messageIndex] = message + return newMessages + }) + } else { + return result?.error || new Error('Failed to save') + } + } else { + return new Error('Failed to save') + } + } + + useEffect(() => { + if (threadIdFromURL) { + setThreadId(threadIdFromURL) + } + }, [threadIdFromURL]) + + const [{ data: contextInfoData, fetching: fetchingContextInfo }] = useQuery({ + query: contextInfoQuery + }) + + const [afterCursor, setAfterCursor] = useState() + + const [{ data: threadData, fetching: fetchingThread, error: threadError }] = + useQuery({ + query: listThreads, + variables: { + ids: [threadId as string] + }, + pause: !threadId + }) + + const [ + { + data: threadMessages, + error: threadMessagesError, + fetching: fetchingMessages, + stale: threadMessagesStale + } + ] = useQuery({ + query: listThreadMessages, + variables: { + threadId: threadId as string, + first: PAGE_SIZE, + after: afterCursor + }, + pause: !threadId || isReady + }) + + useEffect(() => { + if (threadMessagesStale) return + + if (threadMessages?.threadMessages?.edges?.length) { + const messages = threadMessages.threadMessages.edges + .map(o => o.node) + .slice() + setMessages(prev => uniqBy([...prev, ...messages], 'id')) + } + + if (threadMessages?.threadMessages) { + const hasNextPage = threadMessages?.threadMessages?.pageInfo?.hasNextPage + const endCursor = threadMessages?.threadMessages.pageInfo.endCursor + if (hasNextPage && endCursor) { + setAfterCursor(endCursor) + } else { + setIsReady(true) + } + } + }, [threadMessages]) + + const isThreadOwner = useMemo(() => { + if (!meData) return false + if (!threadIdFromURL) return true + + const thread = threadData?.threads.edges[0] + if (!thread) return false + + return meData.me.id === thread.node.userId + }, [meData, threadData, threadIdFromURL]) + + // Compute title + const sources = contextInfoData?.contextInfo.sources + const content = messages?.[0]?.content + const title = useMemo(() => { + if (sources && content) { + return getTitleFromMessages(sources, content, { + maxLength: SLUG_TITLE_MAX_LENGTH + }) + } else { + return '' + } + }, [sources, content]) + + // Update title + useEffect(() => { + if (title) { + document.title = title + } + }, [title]) + + useEffect(() => { + if (threadMessagesError && !isReady) { + setIsReady(true) + } + }, [threadMessagesError]) + + // `/search` -> `/search/{slug}-{threadId}` + const updateThreadURL = (threadId: string) => { + const slug = slugify(title) + const slugWithThreadId = compact([slug, threadId]).join('-') + + const path = updateUrlComponents({ + pathname: `/page/${slugWithThreadId}`, + searchParams: { + del: ['q'] + }, + replace: true + }) + + return location.origin + path + } + + const { + sendUserMessage, + isLoading, + error, + answer, + stop, + regenerate, + deleteThreadMessagePair + } = useThreadRun({ + threadId + }) + + const isLoadingRef = useLatest(isLoading) + + const { selectedModel, isModelLoading, models } = useSelectedModel() + + const currentMessageForDev = useMemo(() => { + return messages.find(item => item.id === messageIdForDev) + }, [messageIdForDev, messages]) + + const valueForDev = useMemo(() => { + if (currentMessageForDev) { + return pick(currentMessageForDev?.attachment, 'doc', 'code') + } + return { + answers: messages + .filter(o => o.role === Role.Assistant) + .map(o => pick(o, 'doc', 'code')) + } + }, [ + messageIdForDev, + currentMessageForDev?.attachment?.code, + currentMessageForDev?.attachment?.doc + ]) + + const onPanelLayout = (sizes: number[]) => { + if (sizes?.[1]) { + setDevPanelSize(sizes[1]) + } + } + + // for synchronizing the active pathname + useEffect(() => { + setActivePathname(pathname) + + if (!isPathnameInitialized) { + setIsPathnameInitialized(true) + } + }, [pathname]) + + useEffect(() => { + const init = () => { + if (initializing.current) return + + initializing.current = true + + setIsReady(true) + } + + if (isPathnameInitialized && !threadIdFromURL) { + init() + } + }, [isPathnameInitialized]) + + // Display the input field with a delayed animatio + useEffect(() => { + if (isReady) { + setTimeout(() => { + setShowSearchInput(true) + }, 300) + } + }, [isReady]) + + const persistenceDisabled = useMemo(() => { + return !threadIdFromURL && some(messages, message => !!message.error) + }, [threadIdFromURL, messages]) + + const { isCopied: isShareLinkCopied, onShare: onClickShare } = useShareThread( + { + threadIdFromURL, + threadIdFromStreaming: threadId, + streamingDone: !isLoading, + updateThreadURL + } + ) + + // Handling the stream response from useThreadRun + useEffect(() => { + // update threadId + if (answer.threadId && answer.threadId !== threadId) { + setThreadId(answer.threadId) + } + + let newMessages = [...messages] + + const currentUserMessageIdx = newMessages.findIndex( + o => o.id === currentUserMessageId + ) + const currentAssistantMessageIdx = newMessages.findIndex( + o => o.id === currentAssistantMessageId + ) + if (currentUserMessageIdx === -1 || currentAssistantMessageIdx === -1) { + return + } + + const currentUserMessage = newMessages[currentUserMessageIdx] + const currentAssistantMessage = newMessages[currentAssistantMessageIdx] + + // update assistant message + currentAssistantMessage.content = answer.content + + // get and format scores from streaming answer + if (!currentAssistantMessage.attachment?.code && !!answer.attachmentsCode) { + currentAssistantMessage.attachment = { + clientCode: null, + doc: currentAssistantMessage.attachment?.doc || null, + code: + answer.attachmentsCode.map(hit => ({ + ...hit.code, + extra: { + scores: hit.scores + } + })) || null + } + } + + // get and format scores from streaming answer + if (!currentAssistantMessage.attachment?.doc && !!answer.attachmentsDoc) { + currentAssistantMessage.attachment = { + clientCode: null, + doc: + answer.attachmentsDoc.map(hit => ({ + ...hit.doc, + extra: { + score: hit.score + } + })) || null, + code: currentAssistantMessage.attachment?.code || null + } + } + + currentAssistantMessage.threadRelevantQuestions = answer?.relevantQuestions + + // update message pair ids + const newUserMessageId = answer.userMessageId + const newAssistantMessageId = answer.assistantMessageId + if ( + newUserMessageId && + newAssistantMessageId && + newUserMessageId !== currentUserMessage.id && + newAssistantMessageId !== currentAssistantMessage.id + ) { + currentUserMessage.id = newUserMessageId + currentAssistantMessage.id = newAssistantMessageId + setCurrentUserMessageId(newUserMessageId) + setCurrentAssistantMessageId(newAssistantMessageId) + } + + // update messages + setMessages(newMessages) + }, [isLoading, answer]) + + // Handling the error response from useThreadRun + useEffect(() => { + if (error) { + const newConversation = [...messages] + const currentAnswer = newConversation.find( + item => item.id === currentAssistantMessageId + ) + if (currentAnswer) { + currentAnswer.error = formatThreadRunErrorMessage(error) + } + } + }, [error]) + + // Delay showing the stop button + const showStopTimeoutId = useRef() + + useEffect(() => { + if (isLoadingRef.current) { + showStopTimeoutId.current = window.setTimeout(() => { + if (!isLoadingRef.current) return + setStopButtonVisible(true) + + // Scroll to the bottom + const container = contentContainerRef?.current + if (container) { + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth' + }) + } + }, 300) + } + + if (!isLoadingRef.current) { + setStopButtonVisible(false) + } + + return () => { + window.clearTimeout(showStopTimeoutId.current) + } + }, [isLoading]) + + useEffect(() => { + if (devPanelOpen) { + devPanelRef.current?.expand() + devPanelRef.current?.resize(devPanelSize) + } else { + devPanelRef.current?.collapse() + } + }, [devPanelOpen]) + + const onSubmitSearch = (question: string, ctx?: ThreadRunContexts) => { + const newUserMessageId = tempNanoId() + const newAssistantMessageId = tempNanoId() + const newUserMessage: ConversationMessage = { + id: newUserMessageId, + role: Role.User, + content: question + } + const newAssistantMessage: ConversationMessage = { + id: newAssistantMessageId, + role: Role.Assistant, + content: '' + } + + const { sourceIdForCodeQuery, sourceIdsForDocQuery, searchPublic } = + getSourceInputs(ctx) + + const codeQuery: InputMaybe = sourceIdForCodeQuery + ? { sourceId: sourceIdForCodeQuery, content: question } + : null + + const docQuery: InputMaybe = { + sourceIds: sourceIdsForDocQuery, + content: question, + searchPublic: !!searchPublic + } + + setCurrentUserMessageId(newUserMessageId) + setCurrentAssistantMessageId(newAssistantMessageId) + setMessages([...messages].concat([newUserMessage, newAssistantMessage])) + + sendUserMessage( + { + content: question + }, + { + generateRelevantQuestions: true, + codeQuery, + docQuery, + modelName: ctx?.modelName + } + ) + } + + // regenerate ths last assistant message + const onRegenerateResponse = () => { + if (!threadId) return + // need to get the sources from contextInfo + if (fetchingContextInfo) return + + const assistantMessageIndex = messages.length - 1 + const userMessageIndex = assistantMessageIndex - 1 + if (assistantMessageIndex === -1 || userMessageIndex <= -1) return + + const prevUserMessageId = messages[userMessageIndex].id + const prevAssistantMessageId = messages[assistantMessageIndex].id + + const newMessages = messages.slice(0, -2) + const userMessage = messages[userMessageIndex] + const newUserMessage: ConversationMessage = { + ...userMessage, + id: tempNanoId() + } + const newAssistantMessage: ConversationMessage = { + id: tempNanoId(), + role: Role.Assistant, + content: '', + attachment: { + code: null, + doc: null, + clientCode: null + }, + error: undefined + } + + const mentions = getMentionsFromText( + newUserMessage.content, + contextInfoData?.contextInfo?.sources + ) + + const { sourceIdForCodeQuery, sourceIdsForDocQuery, searchPublic } = + getSourceInputs(getThreadRunContextsFromMentions(mentions)) + + const codeQuery: InputMaybe = sourceIdForCodeQuery + ? { sourceId: sourceIdForCodeQuery, content: newUserMessage.content } + : null + + const docQuery: InputMaybe = { + sourceIds: sourceIdsForDocQuery, + content: newUserMessage.content, + searchPublic + } + + setCurrentUserMessageId(newUserMessage.id) + setCurrentAssistantMessageId(newAssistantMessage.id) + setMessages([...newMessages, newUserMessage, newAssistantMessage]) + + regenerate({ + threadId, + userMessageId: prevUserMessageId, + assistantMessageId: prevAssistantMessageId, + userMessage: { + content: newUserMessage.content + }, + threadRunOptions: { + generateRelevantQuestions: true, + codeQuery, + docQuery, + modelName: selectedModel + } + }) + } + + const onToggleFullScreen = (fullScreen: boolean) => { + let nextSize = prevDevPanelSize.current + if (fullScreen) { + nextSize = 100 + } else if (nextSize === 100) { + nextSize = 45 + } + devPanelRef.current?.resize(nextSize) + setDevPanelSize(nextSize) + prevDevPanelSize.current = devPanelSize + } + + const onDeleteMessage = (asistantMessageId: string) => { + if (!threadId) return + // find userMessageId by assistantMessageId + const assistantMessageIndex = messages.findIndex( + message => message.id === asistantMessageId + ) + const userMessageIndex = assistantMessageIndex - 1 + const userMessage = messages[assistantMessageIndex - 1] + + if (assistantMessageIndex === -1 || userMessage?.role !== Role.User) { + return + } + + // message pair not successfully created in threadrun + if ( + userMessage.id.startsWith(TEMP_MSG_ID_PREFIX) && + asistantMessageId.startsWith(TEMP_MSG_ID_PREFIX) + ) { + const newMessages = messages + .slice(0, userMessageIndex) + .concat(messages.slice(assistantMessageIndex + 1)) + setMessages(newMessages) + return + } + + deleteThreadMessagePair(threadId, userMessage.id, asistantMessageId).then( + errorMessage => { + if (errorMessage) { + toast.error(errorMessage) + return + } + + // remove userMessage and assistantMessage + const newMessages = messages + .slice(0, userMessageIndex) + .concat(messages.slice(assistantMessageIndex + 1)) + setMessages(newMessages) + } + ) + } + + const onModelSelect = (model: string) => { + updateSelectedModel(model) + } + + const formatedThreadError: ExtendedCombinedError | undefined = useMemo(() => { + if (!isReady || fetchingThread || !threadIdFromURL) return undefined + if (threadError || !threadData?.threads?.edges?.length) { + return threadError || new Error(ERROR_CODE_NOT_FOUND) + } + }, [threadData, fetchingThread, threadError, isReady, threadIdFromURL]) + + const [isFetchingMessages] = useDebounceValue( + fetchingMessages || threadMessages?.threadMessages?.pageInfo?.hasNextPage, + 200 + ) + + const qaPairs = useMemo(() => { + const pairs: Array = [] + let currentPair: ConversationPair = { question: null, answer: null } + messages.forEach(message => { + if (message.role === Role.User) { + currentPair.question = message + } else if (message.role === Role.Assistant) { + if (!currentPair.answer) { + // Take the first answer + currentPair.answer = message + pairs.push(currentPair) + currentPair = { question: null, answer: null } + } + } + }) + + return pairs + }, [messages]) + + const style = isShowDemoBanner + ? { height: `calc(100vh - ${BANNER_HEIGHT})` } + : { height: '100vh' } + + if (isReady && (formatedThreadError || threadMessagesError)) { + return ( + + ) + } + + if (!isReady && (isFetchingMessages || threadMessagesStale)) { + return ( +
+
+
+ + +
+
+ ) + } + + if (!isReady) { + return <> + } + + return ( + +
+
+
+ +
+
+ {/* page title */} +
+

+ Tailwindcss in TabbyML +

+
+
+ +
{meData?.me?.name}
+
+
+
+ + 2 hours ago +
+
+ + 345 +
+
+
+
+ {/* page summary */} + {/* FIXME mock */} + + + {/* sections */} +
+ {qaPairs.map((pair, index) => { + const isLastMessage = index === qaPairs.length - 1 + if (!pair.question) return null + + return ( + + {!!pair.question && ( + + )} + {!!pair.answer && ( + 2} + /> + )} + {!isLastMessage && mode === 'edit' && ( +
+ +
+ )} +
+ ) + })} +
+ {mode === 'edit' && ( +
+
+ + +
+ +
+ )} +
+
+ +
+
+
+ + + +
+
+ {stopButtonVisible && ( + + )} + {/* {!stopButtonVisible && mode === 'view' && ( + + + + + + + + + )} */} +
+ {mode === 'view' && ( +
+ +
+ )} +
+
+
+
+ ) +} + +const updateThreadMessageMutation = graphql(/* GraphQL */ ` + mutation UpdateThreadMessage($input: UpdateMessageInput!) { + updateThreadMessage(input: $input) + } +`) + +interface ThreadMessagesErrorViewProps { + error: ExtendedCombinedError + threadIdFromURL?: string +} +function ThreadMessagesErrorView({ + error, + threadIdFromURL +}: ThreadMessagesErrorViewProps) { + let title = 'Something went wrong' + let description = + 'Failed to fetch the thread, please refresh the page or start a new thread' + + if (error.message === ERROR_CODE_NOT_FOUND) { + return + } + + return ( +
+
+
+
+
+ +
{title}
+
+
{description}
+ + + New Page + +
+
+
+ ) +} + +function getSourceInputs(ctx: ThreadRunContexts | undefined) { + let sourceIdsForDocQuery: string[] = [] + let sourceIdForCodeQuery: string | undefined + let searchPublic = false + + if (ctx) { + sourceIdsForDocQuery = uniq( + compact([ctx?.codeSourceIds?.[0]].concat(ctx.docSourceIds)) + ) + searchPublic = ctx.searchPublic ?? false + sourceIdForCodeQuery = ctx.codeSourceIds?.[0] ?? undefined + } + return { + sourceIdsForDocQuery, + sourceIdForCodeQuery, + searchPublic + } +} + +interface UseShareThreadOptions { + threadIdFromURL?: string + threadIdFromStreaming?: string | null + streamingDone?: boolean + updateThreadURL?: (threadId: string) => string +} + +function useShareThread({ + threadIdFromURL, + threadIdFromStreaming, + streamingDone, + updateThreadURL +}: UseShareThreadOptions) { + const { isCopied, copyToClipboard } = useCopyToClipboard({ + timeout: 2000 + }) + + const setThreadPersisted = useMutation(setThreadPersistedMutation, { + onError(err) { + toast.error(err.message) + } + }) + + const shouldSetThreadPersisted = + !threadIdFromURL && + streamingDone && + threadIdFromStreaming && + updateThreadURL + + const onShare = async () => { + if (isCopied) return + + let url = window.location.href + if (shouldSetThreadPersisted) { + await setThreadPersisted({ threadId: threadIdFromStreaming }) + url = updateThreadURL(threadIdFromStreaming) + } + + copyToClipboard(url) + } + + return { + onShare, + isCopied + } +} + +function formatThreadRunErrorMessage(error?: ExtendedCombinedError) { + if (!error) return 'Failed to fetch' + + if (error.message === '401') { + return 'Unauthorized' + } + + if ( + some(error.graphQLErrors, o => o.extensions?.code === ERROR_CODE_NOT_FOUND) + ) { + return `The thread has expired or does not exist.` + } + + return error.message || 'Failed to fetch' +} diff --git a/ee/tabby-ui/app/page/components/section-content.tsx b/ee/tabby-ui/app/page/components/section-content.tsx new file mode 100644 index 000000000000..c3eff9b03de5 --- /dev/null +++ b/ee/tabby-ui/app/page/components/section-content.tsx @@ -0,0 +1,633 @@ +'use client' + +import { MouseEventHandler, useContext, useMemo, useState } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import DOMPurify from 'dompurify' +import he from 'he' +import { compact, concat, isEmpty } from 'lodash-es' +import { marked } from 'marked' +import { useForm } from 'react-hook-form' +import Textarea from 'react-textarea-autosize' +import { Context } from 'tabby-chat-panel/index' +import * as z from 'zod' + +import { MARKDOWN_CITATION_REGEX } from '@/lib/constants/regex' +import { + Maybe, + MessageAttachmentClientCode, + MessageAttachmentCode +} from '@/lib/gql/generates/graphql' +import { makeFormErrorHandler } from '@/lib/tabby/gql' +import { + AttachmentCodeItem, + AttachmentDocItem, + ExtendedCombinedError, + RelevantCodeContext +} from '@/lib/types' +import { + cn, + formatLineHashForCodeBrowser, + getContent, + getRangeFromAttachmentCode, + getRangeTextFromAttachmentCode +} from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage +} from '@/components/ui/form' +import { + IconBlocks, + IconBug, + IconCheckCircled, + IconChevronRight, + IconCircleDot, + IconEdit, + IconGitMerge, + IconGitPullRequest, + IconLayers, + IconMore, + IconPlus, + IconRefresh, + IconSparkles, + IconSpinner, + IconTrash +} from '@/components/ui/icons' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger +} from '@/components/ui/sheet' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/components/ui/tooltip' +import { ChatContext } from '@/components/chat/chat' +import { CopyButton } from '@/components/copy-button' +import { + ErrorMessageBlock, + MessageMarkdown +} from '@/components/message-markdown' +import { DocDetailView } from '@/components/message-markdown/doc-detail-view' +import { SiteFavicon } from '@/components/site-favicon' +import { UserAvatar } from '@/components/user-avatar' + +import { ConversationMessage, PageContext, SOURCE_CARD_STYLE } from './page' + +export function SectionContent({ + className, + message, + showRelatedQuestion, + isLoading, + clientCode +}: { + className?: string + message: ConversationMessage + showRelatedQuestion: boolean + isLoading?: boolean + isLastAssistantMessage?: boolean + isDeletable?: boolean + clientCode?: Maybe> +}) { + const { + onRegenerateResponse, + onSubmitSearch, + enableDeveloperMode, + contextInfo, + fetchingContextInfo, + onDeleteMessage, + isThreadOwner, + onUpdateMessage, + mode + } = useContext(PageContext) + + const { supportsOnApplyInEditorV2, onNavigateToContext } = + useContext(ChatContext) + + const [isEditing, setIsEditing] = useState(false) + const getCopyContent = (answer: ConversationMessage) => { + if (isEmpty(answer?.attachment?.doc) && isEmpty(answer?.attachment?.code)) { + return answer.content + } + + const content = answer.content + .replace(MARKDOWN_CITATION_REGEX, match => { + const citationNumberMatch = match?.match(/\d+/) + return `[${citationNumberMatch}]` + }) + .trim() + const docCitations = + answer.attachment?.doc + ?.map((doc, idx) => `[${idx + 1}] ${doc.link}`) + .join('\n') ?? '' + const docCitationLen = answer.attachment?.doc?.length ?? 0 + const codeCitations = + answer.attachment?.code + ?.map((code, idx) => { + const lineRangeText = getRangeTextFromAttachmentCode(code) + const filenameText = compact([code.filepath, lineRangeText]).join(':') + return `[${idx + docCitationLen + 1}] ${filenameText}` + }) + .join('\n') ?? '' + const citations = docCitations + codeCitations + + return `${content}\n\nCitations:\n${citations}` + } + + const relevantCodeGitURL = message?.attachment?.code?.[0]?.gitUrl || '' + + const clientCodeContexts: RelevantCodeContext[] = useMemo(() => { + if (!clientCode?.length) return [] + return ( + clientCode.map(code => { + const { startLine, endLine } = getRangeFromAttachmentCode(code) + + return { + kind: 'file', + range: { + start: startLine, + end: endLine + }, + filepath: code.filepath || '', + content: code.content, + git_url: relevantCodeGitURL + } + }) ?? [] + ) + }, [clientCode, relevantCodeGitURL]) + + const serverCodeContexts: RelevantCodeContext[] = useMemo(() => { + return ( + message?.attachment?.code?.map(code => { + const { startLine, endLine } = getRangeFromAttachmentCode(code) + + return { + kind: 'file', + range: { + start: startLine, + end: endLine + }, + filepath: code.filepath, + content: code.content, + git_url: code.gitUrl, + extra: { + scores: code?.extra?.scores + } + } + }) ?? [] + ) + }, [clientCode, message?.attachment?.code]) + + const messageAttachmentClientCode = useMemo(() => { + return clientCode?.map(o => ({ + ...o, + gitUrl: relevantCodeGitURL + })) + }, [clientCode, relevantCodeGitURL]) + + const messageAttachmentDocs = message?.attachment?.doc + + const sources = useMemo(() => { + return concat( + [], + messageAttachmentDocs, + messageAttachmentClientCode, + message.attachment?.code + ) + }, [ + messageAttachmentDocs, + messageAttachmentClientCode, + message.attachment?.code + ]) + const sourceLen = sources.length + + // todo context + const onCodeContextClick = (ctx: Context) => { + if (!ctx.filepath) return + const url = new URL(`${window.location.origin}/files`) + const searchParams = new URLSearchParams() + searchParams.append('redirect_filepath', ctx.filepath) + searchParams.append('redirect_git_url', ctx.git_url) + url.search = searchParams.toString() + + const lineHash = formatLineHashForCodeBrowser({ + start: ctx.range.start, + end: ctx.range.end + }) + if (lineHash) { + url.hash = lineHash + } + + window.open(url.toString()) + } + + const onCodeCitationMouseEnter = (index: number) => {} + + const onCodeCitationMouseLeave = (index: number) => {} + + const openCodeBrowserTab = (code: MessageAttachmentCode) => { + const { startLine, endLine } = getRangeFromAttachmentCode(code) + + if (!code.filepath) return + const url = new URL(`${window.location.origin}/files`) + const searchParams = new URLSearchParams() + searchParams.append('redirect_filepath', code.filepath) + searchParams.append('redirect_git_url', code.gitUrl) + url.search = searchParams.toString() + + const lineHash = formatLineHashForCodeBrowser({ + start: startLine, + end: endLine + }) + if (lineHash) { + url.hash = lineHash + } + + window.open(url.toString()) + } + + const onCodeCitationClick = (code: MessageAttachmentCode) => { + if (code.gitUrl) { + openCodeBrowserTab(code) + } + } + + const handleUpdateAssistantMessage = async (message: ConversationMessage) => { + const error = await onUpdateMessage(message) + if (error) { + return error + } else { + setIsEditing(false) + } + } + + return ( +
+ {/* Section content */} +
+ {isLoading && !message.content && ( + + )} + {isEditing ? ( + setIsEditing(false)} + onSubmit={handleUpdateAssistantMessage} + /> + ) : ( + <> + + {/* if isEditing, do not display error message block */} + {message.error && } + + {!isLoading && !isEditing && ( +
+ {sourceLen > 0 && ( + + +
+ {sourceLen} sources +
+
+ + + Sources + + +
+ {sources.map((x, index) => { + // FIXME id + return + })} +
+ + + +
+
+ )} +
+ {mode === 'view' && ( + + )} + {isThreadOwner && mode === 'edit' && ( + <> + + + + + + + + Move Up + Move Down + Delete Section + + + + )} +
+
+ )} + + )} +
+ + {/* Related questions */} + {showRelatedQuestion && + !isEditing && + !isLoading && + message.threadRelevantQuestions && + message.threadRelevantQuestions.length > 0 && ( +
+
+ +

Suggestions

+
+
+ {message.threadRelevantQuestions?.map( + (relevantQuestion, index) => ( +
+

+ {relevantQuestion} +

+ +
+ ) + )} +
+
+ )} +
+ ) +} + +function SourceCard({ + source +}: { + source: AttachmentDocItem | AttachmentCodeItem +}) { + const { mode } = useContext(PageContext) + const isEditMode = mode === 'edit' + + const isDoc = + source.__typename === 'MessageAttachmentIssueDoc' || + source.__typename === 'MessageAttachmentPullDoc' || + source.__typename === 'MessageAttachmentWebDoc' + + if (isDoc) { + return ( +
+ {isEditMode && } +
window.open(source.link)} + > + +
+
+ ) + } + + return ( +
+ {isEditMode && } +
+
+
+

+ {source.filepath} +

+
+
+
+
+ +

{source.gitUrl}

+
+
+
+
+
+
+ ) +} + +function DocSourceCard({ source }: { source: AttachmentDocItem }) { + const { hostname } = new URL(source.link) + const isIssue = source.__typename === 'MessageAttachmentIssueDoc' + const isPR = source.__typename === 'MessageAttachmentPullDoc' + const author = + source.__typename === 'MessageAttachmentWebDoc' ? undefined : source.author + + const showAvatar = (isIssue || isPR) && !!author + + return ( +
+
+

+ {source.title} +

+ + {showAvatar && ( +
+ +

+ {author?.name} +

+
+ )} + {!showAvatar && ( +

+ {normalizedText(getContent(source))} +

+ )} +
+
+
+
+ +

+ {hostname.replace('www.', '').split('/')[0]} +

+
+
+ {isIssue && ( + <> + {source.closed ? ( + + ) : ( + + )} + {source.closed ? 'Closed' : 'Open'} + + )} + {isPR && ( + <> + {source.merged ? ( + + ) : ( + + )} + {source.merged ? 'Merged' : 'Open'} + + )} +
+
+
+
+ ) +} + +function MessageContentForm({ + message, + onCancel, + onSubmit +}: { + message: ConversationMessage + onCancel: () => void + onSubmit: ( + newMessage: ConversationMessage + ) => Promise +}) { + const formSchema = z.object({ + content: z.string().trim() + }) + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { content: message.content } + }) + const { isSubmitting } = form.formState + const [draftMessage] = useState(message) + + const handleSubmit = async (values: z.infer) => { + const error = await onSubmit({ + ...draftMessage, + content: values.content + }) + + if (error) { + makeFormErrorHandler(form)(error) + } + } + + return ( +
+ + ( + + +