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 && (
+
+
+
+ {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 && (
+
+
+
+ {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 (
+
+
+ )
+}
+
+// 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
+}
diff --git a/ee/tabby-ui/app/search/components/search.tsx b/ee/tabby-ui/app/search/components/search.tsx
index 910d0872cb58..434cbe9a2738 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)
@@ -194,6 +167,35 @@ export function Search() {
return activePathname.match(regex)?.[1]?.split('-').pop()
}, [activePathname])
+ 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)
@@ -253,6 +255,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
@@ -735,7 +744,9 @@ export function Search() {
enableDeveloperMode: enableDeveloperMode.value,
contextInfo: contextInfoData?.contextInfo,
fetchingContextInfo,
- onDeleteMessage
+ onDeleteMessage,
+ isThreadOwner,
+ onUpdateMessage
}}
>
@@ -749,40 +760,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 (
-
+ {!isLastMessage &&
}
+ >
)
+ } else {
+ return null
}
- return <>>
})}
@@ -893,411 +898,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 && (
-
-
-
- {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 && (
-
-
-
- {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/app/search/components/user-message-section.tsx b/ee/tabby-ui/app/search/components/user-message-section.tsx
new file mode 100644
index 000000000000..a37fd0cc2d5e
--- /dev/null
+++ b/ee/tabby-ui/app/search/components/user-message-section.tsx
@@ -0,0 +1,31 @@
+import { HTMLAttributes, useContext } from 'react'
+
+import { cn } from '@/lib/utils'
+import { MessageMarkdown } from '@/components/message-markdown'
+
+import { ConversationMessage, SearchContext } from './search'
+
+interface QuestionBlockProps extends HTMLAttributes {
+ message: ConversationMessage
+}
+
+export function UserMessageSection({
+ message,
+ className,
+ ...props
+}: QuestionBlockProps) {
+ const { contextInfo, fetchingContextInfo } = useContext(SearchContext)
+
+ return (
+
+
+
+ )
+}
diff --git a/ee/tabby-ui/components/prompt-editor/index.tsx b/ee/tabby-ui/components/prompt-editor/index.tsx
index 2ad05cad6d98..2de909b765d2 100644
--- a/ee/tabby-ui/components/prompt-editor/index.tsx
+++ b/ee/tabby-ui/components/prompt-editor/index.tsx
@@ -20,6 +20,7 @@ import {
Extension,
useEditor
} from '@tiptap/react'
+import type { Content as TiptapContent } from '@tiptap/react'
import { ContextInfo, ContextSource } from '@/lib/gql/generates/graphql'
import { useLatest } from '@/lib/hooks/use-latest'
@@ -53,7 +54,7 @@ const CustomKeyboardShortcuts = (onSubmit: (editor: Editor) => void) =>
interface PromptEditorProps {
editable: boolean
- content?: string
+ content?: TiptapContent
contextInfo?: ContextInfo
fetchingContextInfo?: boolean
submitting?: boolean
diff --git a/ee/tabby-ui/components/textarea-search.tsx b/ee/tabby-ui/components/textarea-search.tsx
index 7a6ee12d2b8e..891ecc870a25 100644
--- a/ee/tabby-ui/components/textarea-search.tsx
+++ b/ee/tabby-ui/components/textarea-search.tsx
@@ -47,6 +47,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)