@@ -210,6 +226,29 @@ function MainPanel() {
} target="_blank">
Code Browser
+ {searchFlag.value && isChatEnabled && (
+
+ )}
} onClick={handleSignOut}>
Sign out
{signOutLoading &&
}
diff --git a/ee/tabby-ui/app/globals.css b/ee/tabby-ui/app/globals.css
index a8bfbe91edde..bf209eb9a159 100644
--- a/ee/tabby-ui/app/globals.css
+++ b/ee/tabby-ui/app/globals.css
@@ -91,3 +91,7 @@
.prose-full-width {
max-width: none !important;
}
+
+.dialog-without-close-btn > button {
+ display: none;
+}
diff --git a/ee/tabby-ui/app/search/components/search.css b/ee/tabby-ui/app/search/components/search.css
new file mode 100644
index 000000000000..8ae3228ba942
--- /dev/null
+++ b/ee/tabby-ui/app/search/components/search.css
@@ -0,0 +1,18 @@
+@keyframes sparkle {
+ 0% {
+ opacity: 1;
+ transform: scaleX(1);
+ }
+ 50% {
+ opacity: 0.5;
+ transform: scaleX(-1);
+ }
+ 100% {
+ opacity: 1;
+ transform: scaleX(1);
+ }
+}
+
+.sparkle-animation {
+ animation: sparkle 2s infinite;
+}
\ No newline at end of file
diff --git a/ee/tabby-ui/app/search/components/search.tsx b/ee/tabby-ui/app/search/components/search.tsx
new file mode 100644
index 000000000000..8ea19624a365
--- /dev/null
+++ b/ee/tabby-ui/app/search/components/search.tsx
@@ -0,0 +1,746 @@
+'use client'
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react'
+import Image from 'next/image'
+import logoUrl from '@/assets/tabby.png'
+import { Message } from 'ai'
+import { nanoid } from 'nanoid'
+import remarkGfm from 'remark-gfm'
+import remarkMath from 'remark-math'
+
+import { SESSION_STORAGE_KEY } from '@/lib/constants'
+import { useEnableSearch } from '@/lib/experiment-flags'
+import { useIsChatEnabled } from '@/lib/hooks/use-server-info'
+import { useTabbyAnswer } from '@/lib/hooks/use-tabby-answer'
+import fetcher from '@/lib/tabby/fetcher'
+import { AnswerRequest } from '@/lib/types'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import { CodeBlock } from '@/components/ui/codeblock'
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger
+} from '@/components/ui/hover-card'
+import {
+ IconBlocks,
+ IconChevronRight,
+ IconLayers,
+ IconPlus,
+ IconRefresh,
+ IconSparkles,
+ IconStop
+} from '@/components/ui/icons'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Separator } from '@/components/ui/separator'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTrigger
+} from '@/components/ui/sheet'
+import { Skeleton } from '@/components/ui/skeleton'
+import { ButtonScrollToBottom } from '@/components/button-scroll-to-bottom'
+import { CopyButton } from '@/components/copy-button'
+import { BANNER_HEIGHT, useShowDemoBanner } from '@/components/demo-banner'
+import { MemoizedReactMarkdown } from '@/components/markdown'
+import TextAreaSearch from '@/components/textarea-search'
+
+import './search.css'
+
+interface Source {
+ title: string
+ link: string
+ snippet: string
+}
+
+type ConversationMessage = Message & {
+ relevant_documents?: {
+ title: string
+ link: string
+ snippet: string
+ }[]
+ relevant_questions?: string[]
+ isLoading?: boolean
+ error?: string
+}
+
+type SearchContextValue = {
+ isLoading: boolean
+ onRegenerateResponse: (id: string) => void
+ onSubmitSearch: (question: string) => void
+}
+
+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
+
+export function Search() {
+ const isChatEnabled = useIsChatEnabled()
+ const [searchFlag] = useEnableSearch()
+ const [isShowDemoBanner] = useShowDemoBanner()
+ const [conversation, setConversation] = useState([])
+ const [showStop, setShowStop] = useState(false)
+ const [container, setContainer] = useState(null)
+ const [title, setTitle] = useState('')
+ const [currentLoadindId, setCurrentLoadingId] = useState('')
+ const contentContainerRef = useRef(null)
+
+ const { triggerRequest, isLoading, error, answer, stop } = useTabbyAnswer({
+ fetcher: tabbyFetcher
+ })
+
+ useEffect(() => {
+ const initialQuestion = sessionStorage.getItem(
+ SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG
+ )
+ if (initialQuestion) {
+ onSubmitSearch(initialQuestion)
+ sessionStorage.removeItem(SESSION_STORAGE_KEY.SEARCH_INITIAL_MSG)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (title) document.title = title
+ }, [title])
+
+ useEffect(() => {
+ setContainer(
+ contentContainerRef?.current?.children[1] as HTMLDivElement | null
+ )
+ }, [contentContainerRef?.current])
+
+ // Handling the stream response from useTabbyAnswer
+ useEffect(() => {
+ if (!answer) return
+ const newConversation = [...conversation]
+ const currentAnswer = newConversation.find(
+ item => item.id === currentLoadindId
+ )
+ if (!currentAnswer) return
+ currentAnswer.content = answer.answer_delta || ''
+ currentAnswer.relevant_documents = answer.relevant_documents
+ currentAnswer.relevant_questions = answer.relevant_questions
+ currentAnswer.isLoading = isLoading
+ setConversation(newConversation)
+ }, [isLoading, answer])
+
+ // Handling the error response from useTabbyAnswer
+ useEffect(() => {
+ if (error) {
+ const newConversation = [...conversation]
+ const currentAnswer = newConversation.find(
+ item => item.id === currentLoadindId
+ )
+ if (currentAnswer) {
+ currentAnswer.error =
+ error.message === '401' ? 'Unauthorized' : 'Fail to fetch'
+ currentAnswer.isLoading = false
+ }
+ }
+ }, [error])
+
+ // Delay showing the stop button
+ useEffect(() => {
+ if (isLoading && !showStop) {
+ setTimeout(() => {
+ setShowStop(true)
+
+ // Scroll to the bottom
+ if (container) {
+ container.scrollTo({
+ top: container.scrollHeight,
+ behavior: 'smooth'
+ })
+ }
+ }, 1500)
+ }
+
+ if (!isLoading && showStop) {
+ setShowStop(false)
+ }
+ }, [isLoading])
+
+ const onSubmitSearch = (question: string) => {
+ // FIXME: code query? extra from user's input?
+ 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',
+ content: question
+ }
+ const newAssistantMessage: ConversationMessage = {
+ id: newAssistantId,
+ role: 'assistant',
+ content: '',
+ isLoading: true
+ }
+
+ const answerRequest: AnswerRequest = {
+ messages: [...previousMessages, newUserMessage],
+ doc_query: true,
+ generate_relevant_questions: true
+ }
+
+ setCurrentLoadingId(newAssistantId)
+ setConversation(
+ [...conversation].concat([newUserMessage, newAssistantMessage])
+ )
+ triggerRequest(answerRequest)
+
+ // Update HTML page title
+ if (!title) setTitle(question)
+ }
+
+ const onRegenerateResponse = (id: string) => {
+ const targetAnswerIdx = conversation.findIndex(item => item.id === id)
+ if (targetAnswerIdx < 1) return
+ const targetQuestionIdx = targetAnswerIdx - 1
+ const targetQuestion = conversation[targetQuestionIdx]
+
+ const previousMessages = conversation
+ .slice(0, targetQuestionIdx)
+ .map(message => ({
+ role: message.role,
+ id: message.id,
+ content: message.content
+ }))
+ const newUserMessage = {
+ role: 'user',
+ id: targetQuestion.id,
+ content: targetQuestion.content
+ }
+ const answerRequest: AnswerRequest = {
+ messages: [...previousMessages, newUserMessage],
+ doc_query: true,
+ generate_relevant_questions: true
+ }
+
+ const newConversation = [...conversation]
+ let newTargetAnswer = newConversation[targetAnswerIdx]
+ newTargetAnswer.content = ''
+ newTargetAnswer.error = undefined
+ newTargetAnswer.isLoading = true
+
+ setCurrentLoadingId(newTargetAnswer.id)
+ setConversation(newConversation)
+ triggerRequest(answerRequest)
+ }
+
+ if (!searchFlag.value || !isChatEnabled) {
+ return <>>
+ }
+
+ const noConversation = conversation.length === 0
+ const style = isShowDemoBanner
+ ? { height: `calc(100vh - ${BANNER_HEIGHT})` }
+ : { height: '100vh' }
+ return (
+
+
+
+
+
+ {conversation.map((item, idx) => {
+ if (item.role === 'user') {
+ return (
+
+ {idx !== 0 &&
}
+
+
+
+
+ )
+ }
+ if (item.role === 'assistant') {
+ return (
+
+ )
+ }
+ return <>>
+ })}
+
+
+
+
+ {container && (
+
+ )}
+
+
+ {noConversation && (
+ <>
+
+
+ The Private Search Assistant
+
+ >
+ )}
+ {!isLoading && (
+
+
+
+ )}
+
+
+
+
+ )
+}
+
+function AnswerBlock({
+ question,
+ answer,
+ showRelatedQuestion
+}: {
+ question: string
+ answer: ConversationMessage
+ showRelatedQuestion: boolean
+}) {
+ const { onRegenerateResponse, onSubmitSearch, isLoading } =
+ useContext(SearchContext)
+
+ const getCopyContent = (answer: ConversationMessage) => {
+ if (!answer.relevant_documents) return answer.content
+
+ const citationMatchRegex = /\[\[?citation:\s*\d+\]?\]/g
+ const content = answer.content
+ .replace(citationMatchRegex, (match, p1) => {
+ const citationNumberMatch = match?.match(/\d+/)
+ return `[${citationNumberMatch}]`
+ })
+ .trim()
+ const citations = answer.relevant_documents
+ .map((relevent, idx) => `[${idx + 1}] ${relevent.link}`)
+ .join('\n')
+ return `${content}\n\nCitations:\n${citations}`
+ }
+
+ return (
+
+ {/* Relevant documents */}
+ {answer.relevant_documents && answer.relevant_documents.length > 0 && (
+
+
+
+ {answer.relevant_documents.slice(0, 3).map((source, index) => (
+
+ ))}
+ {answer.relevant_documents &&
+ answer.relevant_documents.length > 3 && (
+
+
+
+
+
+ {answer.relevant_documents
+ .slice(3, 6)
+ .map((source, idx) => {
+ const { hostname } = new URL(source.link)
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+ {answer.relevant_documents.length} resources
+
+
+
+
+ {answer.relevant_documents.map((source, index) => (
+
+ ))}
+
+
+
+
+ )}
+
+
+ )}
+
+ {/* Answer content */}
+
+
+ {answer.isLoading && !answer.content && (
+
+ )}
+
+
+ {answer.error &&
}
+
+ {!answer.isLoading && (
+
+
+ {!isLoading && (
+
+ )}
+
+ )}
+
+
+ {/* Related questions */}
+ {showRelatedQuestion &&
+ !answer.isLoading &&
+ answer.relevant_questions &&
+ answer.relevant_questions.length > 0 && (
+
+
+
+ {answer.relevant_questions?.map((related, index) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function SourceBlock({ source, index }: { source: Source; index: number }) {
+ return (
+
+
{index}.
+
window.open(source.link)}
+ >
+
{source.title}
+
{source.snippet}
+
+
+ )
+}
+
+function SourceCard({ source, index }: { source: Source; index: number }) {
+ const { hostname } = new URL(source.link)
+ return (
+ window.open(source.link)}
+ >
+
+ {source.title}
+
+
+
+
+
+
+ {hostname.replace('www.', '').split('.')[0]}
+
+
.
+
{index}
+
+
+
+ )
+}
+
+function MessageMarkdown({
+ message,
+ headline = false,
+ sources
+}: {
+ message: string
+ headline?: boolean
+ sources?: Source[]
+}) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (children.length) {
+ return (
+
+ {children.map((childrenItem, index) => {
+ if (typeof childrenItem === 'string') {
+ const citationMatchRegex = /\[\[?citation:\s*\d+\]?\]/g
+ const textList = childrenItem.split(citationMatchRegex)
+ const citationList = childrenItem.match(citationMatchRegex)
+ return (
+
+ {textList.map((text, index) => {
+ const citation = citationList?.[index]
+ const citationNumberMatch = citation?.match(/\d+/)
+ const citationIndex = citationNumberMatch
+ ? parseInt(citationNumberMatch[0], 10)
+ : null
+ const source =
+ citationIndex !== null
+ ? sources?.[citationIndex - 1]
+ : null
+ const sourceUrl = source ? new URL(source.link) : null
+ return (
+
+ {text && {text}}
+ {source && (
+
+
+ window.open(source.link)}
+ >
+ {citationIndex}
+
+
+
+
+
+
+
+ {sourceUrl!.hostname}
+
+
+
window.open(source.link)}
+ >
+ {source.title}
+
+
+ {source.snippet}
+
+
+
+
+ )}
+
+ )
+ })}
+
+ )
+ }
+
+ return
{childrenItem}
+ })}
+
+ )
+ }
+
+ return {children}
+ },
+ code({ node, inline, className, children, ...props }) {
+ if (children.length) {
+ if (children[0] == '▍') {
+ return (
+ ▍
+ )
+ }
+
+ children[0] = (children[0] as string).replace('`▍`', '▍')
+ }
+
+ const match = /language-(\w+)/.exec(className || '')
+
+ if (inline) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+ )
+ }
+ }}
+ >
+ {message}
+
+ )
+}
+
+function SiteFavicon({
+ hostname,
+ className
+}: {
+ hostname: string
+ className?: string
+}) {
+ return (
+
+ )
+}
+
+function ErrorMessageBlock({ error = 'Fail to fetch' }: { error?: string }) {
+ const errorMessage = useMemo(() => {
+ let jsonString = JSON.stringify(
+ {
+ error: true,
+ message: error
+ },
+ null,
+ 2
+ )
+ const markdownJson = '```\n' + jsonString + '\n```'
+ return markdownJson
+ }, [error])
+ return (
+
+ {children}
+
+ )
+ }
+ }}
+ >
+ {errorMessage}
+
+ )
+}
diff --git a/ee/tabby-ui/app/search/page.tsx b/ee/tabby-ui/app/search/page.tsx
new file mode 100644
index 000000000000..508b4f730fef
--- /dev/null
+++ b/ee/tabby-ui/app/search/page.tsx
@@ -0,0 +1,11 @@
+import { Metadata } from 'next'
+
+import { Search } from './components/search'
+
+export const metadata: Metadata = {
+ title: 'Search'
+}
+
+export default function SearchPage() {
+ return
+}
diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx
index 68ed4cc9e912..014c08b52a30 100644
--- a/ee/tabby-ui/components/chat/chat.tsx
+++ b/ee/tabby-ui/components/chat/chat.tsx
@@ -16,12 +16,12 @@ import {
} from '@/lib/types/chat'
import { cn, nanoid } from '@/lib/utils'
+import { useTabbyAnswer } from '../../lib/hooks/use-tabby-answer'
import { ListSkeleton } from '../skeleton'
import { ChatPanel } from './chat-panel'
import { ChatScrollAnchor } from './chat-scroll-anchor'
import { EmptyScreen } from './empty-screen'
import { QuestionAnswerList } from './question-answer'
-import { useTabbyAnswer } from './use-tabby-answer'
type ChatContextValue = {
isLoading: boolean
diff --git a/ee/tabby-ui/components/copy-button.tsx b/ee/tabby-ui/components/copy-button.tsx
index dffaaeae2a60..4f147fc103a4 100644
--- a/ee/tabby-ui/components/copy-button.tsx
+++ b/ee/tabby-ui/components/copy-button.tsx
@@ -10,12 +10,14 @@ import { IconCheck, IconCopy } from './ui/icons'
interface CopyButtonProps extends ButtonProps {
value: string
onCopyContent?: (value: string) => void
+ text?: string
}
export function CopyButton({
className,
value,
onCopyContent,
+ text,
...props
}: CopyButtonProps) {
const { isCopied, copyToClipboard } = useCopyToClipboard({
@@ -33,13 +35,14 @@ export function CopyButton({
return (
)
}
diff --git a/ee/tabby-ui/components/textarea-search.tsx b/ee/tabby-ui/components/textarea-search.tsx
new file mode 100644
index 000000000000..14f714027b5e
--- /dev/null
+++ b/ee/tabby-ui/components/textarea-search.tsx
@@ -0,0 +1,83 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import TextareaAutosize from 'react-textarea-autosize'
+
+import { cn } from '@/lib/utils'
+
+import { IconArrowRight } from './ui/icons'
+
+export default function TextAreaSearch({
+ onSearch,
+ className,
+ placeholder
+}: {
+ onSearch: (value: string) => void
+ className?: string
+ placeholder?: string
+}) {
+ const [isShow, setIsShow] = useState(false)
+ const [isFocus, setIsFocus] = useState(false)
+ const [value, setValue] = useState('')
+
+ useEffect(() => {
+ // Ensure the textarea height remains consistent during rendering
+ setIsShow(true)
+ }, [])
+
+ const onSearchKeyDown = (e: React.KeyboardEvent
) => {
+ if (e.key === 'Enter' && !e.shiftKey) return e.preventDefault()
+ }
+
+ const onSearchKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ return search()
+ }
+ }
+
+ const search = () => {
+ if (!value) return
+ onSearch(value)
+ setValue('')
+ }
+
+ return (
+
+
setIsFocus(true)}
+ onBlur={() => setIsFocus(false)}
+ onChange={e => setValue(e.target.value)}
+ value={value}
+ />
+ 0
+ }
+ )}
+ onClick={search}
+ >
+
+
+
+ )
+}
diff --git a/ee/tabby-ui/components/ui/dialog.tsx b/ee/tabby-ui/components/ui/dialog.tsx
index 849798504b3b..5509e2ddbfca 100644
--- a/ee/tabby-ui/components/ui/dialog.tsx
+++ b/ee/tabby-ui/components/ui/dialog.tsx
@@ -2,26 +2,17 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
+import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
-import { IconClose } from '@/components/ui/icons'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
-const DialogPortal = ({
- className,
- children,
- ...props
-}: DialogPrimitive.DialogPortalProps) => (
-
-
- {children}
-
-
-)
-DialogPortal.displayName = DialogPrimitive.Portal.displayName
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef,
@@ -30,7 +21,7 @@ const DialogOverlay = React.forwardRef<
{children}
-
+
Close
@@ -119,6 +110,9 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx
index fe43d205bfe0..96195dd7fd65 100644
--- a/ee/tabby-ui/components/ui/icons.tsx
+++ b/ee/tabby-ui/components/ui/icons.tsx
@@ -2,7 +2,17 @@
import * as React from 'react'
// FIXME(wwayne): Review each icons and consider re-export from `lucide-react`.
-import { BookOpenText, ChevronsDownUp, GitFork, Mail, Star } from 'lucide-react'
+import {
+ Blocks,
+ BookOpenText,
+ ChevronsDownUp,
+ GitFork,
+ Layers2,
+ Mail,
+ Search,
+ Sparkles,
+ Star
+} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -1414,6 +1424,13 @@ const IconGitFork = ({
)
+function IconBlocks({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
function IconVSCode({ className, ...props }: React.ComponentProps<'svg'>) {
return (