diff --git a/ee/tabby-ui/app/search/components/search.tsx b/ee/tabby-ui/app/search/components/search.tsx index 0d16f5d2d724..47636b282c7a 100644 --- a/ee/tabby-ui/app/search/components/search.tsx +++ b/ee/tabby-ui/app/search/components/search.tsx @@ -3,17 +3,12 @@ import { createContext, CSSProperties, - MouseEventHandler, - useContext, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' -import DOMPurify from 'dompurify' -import he from 'he' -import { marked } from 'marked' import { nanoid } from 'nanoid' import { @@ -28,34 +23,22 @@ import { useIsChatEnabled } from '@/lib/hooks/use-server-info' import { AttachmentCodeItem, AttachmentDocItem, - RelevantCodeContext, ThreadRunContexts } from '@/lib/types' import { cn, - formatLineHashForCodeBrowser, getMentionsFromText, - getRangeFromAttachmentCode, - getRangeTextFromAttachmentCode, getThreadRunContextsFromMentions, getTitleFromMessages } from '@/lib/utils' import { Button, buttonVariants } from '@/components/ui/button' import { - IconBlocks, - IconBug, IconCheck, IconChevronLeft, - IconChevronRight, IconFileSearch, - IconLayers, IconPlus, - IconRefresh, IconShare, - IconSparkles, - IconSpinner, - IconStop, - IconTrash + IconStop } from '@/components/ui/icons' import { ResizableHandle, @@ -63,11 +46,8 @@ import { ResizablePanelGroup } from '@/components/ui/resizable' import { ScrollArea } from '@/components/ui/scroll-area' -import { Separator } from '@/components/ui/separator' -import { Skeleton } from '@/components/ui/skeleton' import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom' import { ClientOnly } from '@/components/client-only' -import { CopyButton } from '@/components/copy-button' import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner' import TextAreaSearch from '@/components/textarea-search' import { ThemeToggle } from '@/components/theme-toggle' @@ -78,13 +58,11 @@ import './search.css' import Link from 'next/link' import slugify from '@sindresorhus/slugify' -import { compact, isEmpty, pick, some, uniq, uniqBy } from 'lodash-es' +import { compact, pick, some, uniq, uniqBy } from 'lodash-es' import { ImperativePanelHandle } from 'react-resizable-panels' import { toast } from 'sonner' -import { Context } from 'tabby-chat-panel/index' import { useQuery } from 'urql' -import { MARKDOWN_CITATION_REGEX } from '@/lib/constants/regex' import { graphql } from '@/lib/gql/generates' import { CodeQueryInput, @@ -93,11 +71,11 @@ import { InputMaybe, Maybe, Message, - MessageAttachmentCode, Role } from '@/lib/gql/generates/graphql' import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' import { useDebounceValue } from '@/lib/hooks/use-debounce' +import { useMe } from '@/lib/hooks/use-me' import useRouterStuff from '@/lib/hooks/use-router-stuff' import { ExtendedCombinedError, useThreadRun } from '@/lib/hooks/use-thread-run' import { clearHomeScrollPosition } from '@/lib/stores/scroll-store' @@ -107,22 +85,14 @@ import { listThreadMessages, listThreads } from '@/lib/tabby/query' -import { - Tooltip, - TooltipContent, - TooltipTrigger -} from '@/components/ui/tooltip' -import { CodeReferences } from '@/components/chat/code-references' -import { - ErrorMessageBlock, - MessageMarkdown, - SiteFavicon -} from '@/components/message-markdown' +import { Separator } from '@/components/ui/separator' +import { AssistantMessageSection } from './assistant-message-section' import { DevPanel } from './dev-panel' import { MessagesSkeleton } from './messages-skeleton' +import { UserMessageSection } from './user-message-section' -type ConversationMessage = Omit< +export type ConversationMessage = Omit< Message, '__typename' | 'updatedAt' | 'createdAt' | 'attachment' | 'threadId' > & { @@ -130,8 +100,8 @@ type ConversationMessage = Omit< threadRelevantQuestions?: Maybe error?: string attachment?: { - code: Maybe> - doc: Maybe> + code: Maybe> | undefined + doc: Maybe> | undefined } } @@ -147,13 +117,15 @@ type SearchContextValue = { contextInfo: ContextInfo | undefined fetchingContextInfo: boolean onDeleteMessage: (id: string) => void + isThreadOwner: boolean + onUpdateMessage: (message: ConversationMessage) => Promise } export const SearchContext = createContext( {} as SearchContextValue ) -const SOURCE_CARD_STYLE = { +export const SOURCE_CARD_STYLE = { compress: 5.3, expand: 6.3 } @@ -164,6 +136,7 @@ const TEMP_MSG_ID_PREFIX = '_temp_msg_' const tempNanoId = () => `${TEMP_MSG_ID_PREFIX}${nanoid()}` export function Search() { + const [{ data: meData }] = useMe() const { updateUrlComponents, pathname } = useRouterStuff() const [activePathname, setActivePathname] = useState() const [isPathnameInitialized, setIsPathnameInitialized] = useState(false) @@ -195,6 +168,35 @@ export function Search() { }, [activePathname]) const [selectedModel, setSelectedModel] = useState('') + const updateThreadMessage = useMutation(updateThreadMessageMutation) + + const onUpdateMessage = async (message: ConversationMessage) => { + 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 { + // FIXME error handling + return result?.error?.message || 'Failed to save' + } + } else { + return 'Failed to save' + } + } + useEffect(() => { if (threadIdFromURL) { setThreadId(threadIdFromURL) @@ -254,6 +256,13 @@ export function Search() { } }, [threadMessages]) + const isThreadOwner = useMemo(() => { + if (!threadId) return true + + if (!meData || !threadData?.threads?.edges?.length) return false + return meData.me.id === threadData.threads.edges[0].node.userId + }, [meData, threadData, threadId]) + // Compute title const sources = contextInfoData?.contextInfo.sources const content = messages?.[0]?.content @@ -368,7 +377,7 @@ export function Search() { if (initialMessage) { sessionStorage.removeItem(SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG) sessionStorage.removeItem(SESSION_STORAGE_KEY.SEARCH_INITIAL_CONTEXTS) - + setSelectedModel(initialThreadRunContext.modelName) setIsReady(true) onSubmitSearch(initialMessage, initialThreadRunContext) @@ -739,7 +748,9 @@ export function Search() { enableDeveloperMode: enableDeveloperMode.value, contextInfo: contextInfoData?.contextInfo, fetchingContextInfo, - onDeleteMessage + onDeleteMessage, + isThreadOwner, + onUpdateMessage }} >
@@ -753,40 +764,34 @@ export function Search() {
- {messages.map((item, idx) => { - if (item.role === Role.User) { + {/* messages */} + {messages.map((message, index) => { + const isLastMessage = index === messages.length - 1 + if (message.role === Role.User) { return ( -
- {idx !== 0 && } -
- -
-
+ ) - } - if (item.role === Role.Assistant) { - const isLastAssistantMessage = - idx === messages.length - 1 + } else if (message.role === Role.Assistant) { return ( -
- 2} + <> + 2} /> -
+ {!isLastMessage && } + ) + } else { + return null } - return <> })}
@@ -899,411 +904,18 @@ export function Search() { ) } -function AnswerBlock({ - answer, - showRelatedQuestion, - isLoading, - isLastAssistantMessage, - deletable -}: { - answer: ConversationMessage - showRelatedQuestion: boolean - isLoading?: boolean - isLastAssistantMessage?: boolean - deletable?: boolean -}) { - const { - onRegenerateResponse, - onSubmitSearch, - setDevPanelOpen, - setConversationIdForDev, - enableDeveloperMode, - contextInfo, - fetchingContextInfo, - onDeleteMessage - } = useContext(SearchContext) - - 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 totalHeightInRem = answer.attachment?.doc?.length - ? Math.ceil(answer.attachment.doc.length / 4) * SOURCE_CARD_STYLE.expand + - 0.5 * Math.floor(answer.attachment.doc.length / 4) - : 0 - - const relevantCodeContexts: RelevantCodeContext[] = useMemo(() => { - return ( - answer?.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 - } - } - }) ?? [] - ) - }, [answer?.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 - (answer?.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 messageAttachmentDocs = answer?.attachment?.doc - const messageAttachmentCode = answer?.attachment?.code - - 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(answer.id) - setDevPanelOpen(true) - }} - highlightIndex={relevantCodeHighlightIndex} - /> - )} - - {isLoading && !answer.content && ( - - )} - - {answer.error && } - - {!isLoading && ( -
- - {!isLoading && !fetchingContextInfo && isLastAssistantMessage && ( - - )} - {deletable && ( - - )} -
- )} -
- - {/* Related questions */} - {showRelatedQuestion && - !isLoading && - answer.threadRelevantQuestions && - answer.threadRelevantQuestions.length > 0 && ( -
-
- -

Suggestions

-
-
- {answer.threadRelevantQuestions?.map( - (relevantQuestion, index) => ( -
-

- {relevantQuestion} -

- -
- ) - )} -
-
- )} -
- ) -} - -// Remove HTML and Markdown format -const normalizedText = (input: string) => { - const sanitizedHtml = DOMPurify.sanitize(input, { - ALLOWED_TAGS: [], - ALLOWED_ATTR: [] - }) - const parsed = marked.parse(sanitizedHtml) as string - const decoded = he.decode(parsed) - const plainText = decoded.replace(/<\/?[^>]+(>|$)/g, '') - return plainText -} - -function SourceCard({ - conversationId, - source, - showMore, - showDevTooltip -}: { - conversationId: string - source: AttachmentDocItem - showMore: boolean - showDevTooltip?: boolean -}) { - 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)} - > -
-
-

- {source.title} -

-

- {normalizedText(source.content)} -

-
-
-
- -

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

-
-
-
-
-
- -

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

-
-
- ) -} - const setThreadPersistedMutation = graphql(/* GraphQL */ ` mutation SetThreadPersisted($threadId: ID!) { setThreadPersisted(threadId: $threadId) } `) +const updateThreadMessageMutation = graphql(/* GraphQL */ ` + mutation UpdateThreadMessage($input: UpdateMessageInput!) { + updateThreadMessage(input: $input) + } +`) + type HeaderProps = { threadIdFromURL?: string streamingDone?: boolean diff --git a/ee/tabby-ui/components/textarea-search.tsx b/ee/tabby-ui/components/textarea-search.tsx index 18e6c8d208d4..cb6401f59bb8 100644 --- a/ee/tabby-ui/components/textarea-search.tsx +++ b/ee/tabby-ui/components/textarea-search.tsx @@ -1,6 +1,13 @@ 'use client' -import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' +import { + Dispatch, + SetStateAction, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { Editor } from '@tiptap/react' import { ContextInfo } from '@/lib/gql/generates/graphql' @@ -66,6 +73,7 @@ export default function TextAreaSearch({ isFollowup?: boolean contextInfo?: ContextInfo fetchingContextInfo: boolean + onValueChange?: (value: string | undefined) => void }) { const [isShow, setIsShow] = useState(false) const [isFocus, setIsFocus] = useState(false) @@ -95,7 +103,8 @@ export default function TextAreaSearch({ }, []) useEffect(() => { - if (!modelName) onModelSelect(modelInfo?.chat?.length ? modelInfo?.chat[0] : '') + if (!modelName) + onModelSelect(modelInfo?.chat?.length ? modelInfo?.chat[0] : '') }, [modelInfo]) const onWrapperClick = () => { @@ -111,7 +120,10 @@ export default function TextAreaSearch({ if (!text) return const mentions = getMentionsFromText(text, contextInfo?.sources) - const ctx: ThreadRunContexts = { ...getThreadRunContextsFromMentions(mentions), modelName } + const ctx: ThreadRunContexts = { + ...getThreadRunContextsFromMentions(mentions), + modelName + } // do submit onSearch(text, ctx) @@ -309,8 +321,6 @@ export default function TextAreaSearch({ - -
)