diff --git a/ee/tabby-ui/app/search/components/assistant-message-section.tsx b/ee/tabby-ui/app/search/components/assistant-message-section.tsx new file mode 100644 index 000000000000..940725f1be2c --- /dev/null +++ b/ee/tabby-ui/app/search/components/assistant-message-section.tsx @@ -0,0 +1,602 @@ +'use client' + +import './search.css' + +import { MouseEventHandler, useContext, useMemo, useState } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import DOMPurify from 'dompurify' +import he from 'he' +import { compact, 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 { MessageAttachmentCode } from '@/lib/gql/generates/graphql' +import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' +import { AttachmentDocItem, RelevantCodeContext } from '@/lib/types' +import { + cn, + formatLineHashForCodeBrowser, + getRangeFromAttachmentCode, + getRangeTextFromAttachmentCode +} from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage +} from '@/components/ui/form' +import { + IconBlocks, + IconBug, + IconChevronRight, + IconEdit, + IconLayers, + IconPlus, + IconRefresh, + IconRemove, + IconSparkles, + IconSpinner, + IconTrash +} from '@/components/ui/icons' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/components/ui/tooltip' +import { CodeReferences } from '@/components/chat/code-references' +import { CopyButton } from '@/components/copy-button' +import { + ErrorMessageBlock, + MessageMarkdown, + SiteFavicon +} from '@/components/message-markdown' + +import { ConversationMessage, SearchContext, SOURCE_CARD_STYLE } from './search' + +export function AssistantMessageSection({ + message, + showRelatedQuestion, + isLoading, + isLastAssistantMessage, + isDeletable, + className +}: { + message: ConversationMessage + showRelatedQuestion: boolean + isLoading?: boolean + isLastAssistantMessage?: boolean + isDeletable?: boolean + className?: string +}) { + const { + onRegenerateResponse, + onSubmitSearch, + setDevPanelOpen, + setConversationIdForDev, + enableDeveloperMode, + contextInfo, + fetchingContextInfo, + onDeleteMessage, + isThreadOwner, + onUpdateMessage + } = useContext(SearchContext) + + const [isEditing, setIsEditing] = useState(false) + const [showMoreSource, setShowMoreSource] = useState(false) + const [relevantCodeHighlightIndex, setRelevantCodeHighlightIndex] = useState< + number | undefined + >(undefined) + 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 IconAnswer = isLoading ? IconSpinner : IconSparkles + + const messageAttachmentDocs = message?.attachment?.doc + const messageAttachmentCode = message?.attachment?.code + + const totalHeightInRem = messageAttachmentDocs?.length + ? Math.ceil(messageAttachmentDocs.length / 4) * SOURCE_CARD_STYLE.expand + + 0.5 * Math.floor(messageAttachmentDocs.length / 4) + + 0.5 + : 0 + + const relevantCodeContexts: 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 + } + } + }) ?? [] + ) + }, [message?.attachment?.code]) + + 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) => { + setRelevantCodeHighlightIndex( + index - 1 - (message?.attachment?.doc?.length || 0) + ) + } + + const onCodeCitationMouseLeave = (index: number) => { + setRelevantCodeHighlightIndex(undefined) + } + + 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) => { + openCodeBrowserTab(code) + } + + const handleUpdateAssistantMessage = async (message: ConversationMessage) => { + const errorMessage = await onUpdateMessage(message) + if (errorMessage) { + return errorMessage + } else { + setIsEditing(false) + } + } + + return ( +
+ {/* document search hits */} + {messageAttachmentDocs && messageAttachmentDocs.length > 0 && ( +
+
+ +

Sources

+
+
+ {messageAttachmentDocs.map((source, index) => ( + + ))} +
+ +
+ )} + + {/* Answer content */} +
+
+ +

Answer

+ {enableDeveloperMode && ( + + )} +
+ + {/* code search hits */} + {messageAttachmentCode && messageAttachmentCode.length > 0 && ( + { + setConversationIdForDev(message.id) + setDevPanelOpen(true) + }} + highlightIndex={relevantCodeHighlightIndex} + /> + )} + + {isLoading && !message.content && ( + + )} + {isEditing ? ( + setIsEditing(false)} + onSubmit={handleUpdateAssistantMessage} + /> + ) : ( + <> + + {/* if isEditing, do not display error message block */} + {message.error && } + + {!isLoading && !isEditing && ( +
+
+ {!isLoading && + !fetchingContextInfo && + isLastAssistantMessage && ( + + )} + {isDeletable && ( + + )} +
+
+ + {isThreadOwner && ( + + )} +
+
+ )} + + )} +
+ + {/* Related questions */} + {showRelatedQuestion && + !isEditing && + !isLoading && + message.threadRelevantQuestions && + message.threadRelevantQuestions.length > 0 && ( +
+
+ +

Suggestions

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

+ {relevantQuestion} +

+ +
+ ) + )} +
+
+ )} +
+ ) +} + +function SourceCard({ + conversationId, + source, + showMore, + showDevTooltip, + isDeletable, + onDelete +}: { + conversationId: string + source: AttachmentDocItem + showMore: boolean + showDevTooltip?: boolean + isDeletable?: boolean + onDelete?: () => void +}) { + const { setDevPanelOpen, setConversationIdForDev } = useContext(SearchContext) + const { hostname } = new URL(source.link) + const [devTooltipOpen, setDevTooltipOpen] = useState(false) + + const onOpenChange = (v: boolean) => { + if (!showDevTooltip) return + setDevTooltipOpen(v) + } + + const onTootipClick: MouseEventHandler = e => { + e.stopPropagation() + setConversationIdForDev(conversationId) + setDevPanelOpen(true) + } + + return ( + + +
window.open(source.link)} + > + {isDeletable && ( +
+ +
+ )} +
+
+

+ {source.title} +

+

+ {normalizedText(source.content)} +

+
+
+
+ +

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

+
+
+
+
+
+ +

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

+
+
+ ) +} + +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 { content } = form.watch() + const isEmptyContent = !content || isEmpty(content.trim()) + const [draftMessage] = useState(message) + const { formRef, onKeyDown } = useEnterSubmit() + + const handleSubmit = async (values: z.infer) => { + const errorMessage = await onSubmit({ + ...draftMessage, + content: values.content + }) + if (errorMessage) { + form.setError('root', { message: errorMessage }) + } + } + + return ( +
+ + ( + + +