diff --git a/ee/tabby-schema/src/schema/mod.rs b/ee/tabby-schema/src/schema/mod.rs index e5306c25f463..c2a9bd9fd169 100644 --- a/ee/tabby-schema/src/schema/mod.rs +++ b/ee/tabby-schema/src/schema/mod.rs @@ -1178,7 +1178,7 @@ impl Mutation { /// Turn on persisted status for a thread. async fn set_thread_persisted(ctx: &Context, thread_id: ID) -> Result { - let user = check_user(ctx).await?; + let user = check_user_allow_auth_token(ctx).await?; let svc = ctx.locator.thread(); let Some(thread) = svc.get(&thread_id).await? else { return Err(CoreError::NotFound("Thread not found")); diff --git a/ee/tabby-ui/app/search/components/assistant-message-section.tsx b/ee/tabby-ui/app/search/components/assistant-message-section.tsx index 2aa7f3cc175a..7ec479e603c7 100644 --- a/ee/tabby-ui/app/search/components/assistant-message-section.tsx +++ b/ee/tabby-ui/app/search/components/assistant-message-section.tsx @@ -12,7 +12,11 @@ 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 { + Maybe, + MessageAttachmentClientCode, + MessageAttachmentCode +} from '@/lib/gql/generates/graphql' import { makeFormErrorHandler } from '@/lib/tabby/gql' import { AttachmentDocItem, @@ -68,19 +72,21 @@ import { import { ConversationMessage, SearchContext, SOURCE_CARD_STYLE } from './search' export function AssistantMessageSection({ + className, message, showRelatedQuestion, isLoading, isLastAssistantMessage, isDeletable, - className + clientCode }: { + className?: string message: ConversationMessage showRelatedQuestion: boolean isLoading?: boolean isLastAssistantMessage?: boolean isDeletable?: boolean - className?: string + clientCode?: Maybe> }) { const { onRegenerateResponse, @@ -133,16 +139,29 @@ export function AssistantMessageSection({ const IconAnswer = isLoading ? IconSpinner : IconSparkles - const messageAttachmentDocs = message?.attachment?.doc - const messageAttachmentCode = message?.attachment?.code + const relevantCodeGitURL = message?.attachment?.code?.[0]?.gitUrl || '' - const totalHeightInRem = messageAttachmentDocs?.length - ? Math.ceil(messageAttachmentDocs.length / 4) * SOURCE_CARD_STYLE.expand + - 0.5 * Math.floor(messageAttachmentDocs.length / 4) + - 0.5 - : 0 + 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 relevantCodeContexts: RelevantCodeContext[] = useMemo(() => { + const serverCodeContexts: RelevantCodeContext[] = useMemo(() => { return ( message?.attachment?.code?.map(code => { const { startLine, endLine } = getRangeFromAttachmentCode(code) @@ -162,7 +181,25 @@ export function AssistantMessageSection({ } }) ?? [] ) - }, [message?.attachment?.code]) + }, [clientCode, message?.attachment?.code]) + + const messageAttachmentClientCode = useMemo(() => { + return clientCode?.map(o => ({ + ...o, + gitUrl: relevantCodeGitURL + })) + }, [clientCode, relevantCodeGitURL]) + + const messageAttachmentDocs = message?.attachment?.doc + const messageAttachmentCodeLen = + (messageAttachmentClientCode?.length || 0) + + (message.attachment?.code?.length || 0) + + const totalHeightInRem = messageAttachmentDocs?.length + ? Math.ceil(messageAttachmentDocs.length / 4) * SOURCE_CARD_STYLE.expand + + 0.5 * Math.floor(messageAttachmentDocs.length / 4) + + 0.5 + : 0 const onCodeContextClick = (ctx: Context) => { if (!ctx.filepath) return @@ -215,7 +252,9 @@ export function AssistantMessageSection({ } const onCodeCitationClick = (code: MessageAttachmentCode) => { - openCodeBrowserTab(code) + if (code.gitUrl) { + openCodeBrowserTab(code) + } } const handleUpdateAssistantMessage = async (message: ConversationMessage) => { @@ -294,14 +333,16 @@ export function AssistantMessageSection({ )} - {/* code search hits */} - {messageAttachmentCode && messageAttachmentCode.length > 0 && ( + {/* attachment clientCode & code */} + {messageAttachmentCodeLen > 0 && ( { setConversationIdForDev(message.id) setDevPanelOpen(true) @@ -324,7 +365,8 @@ export function AssistantMessageSection({ error?: string attachment?: { + clientCode?: Maybe> | undefined code: Maybe> | undefined doc: Maybe> | undefined } } +type ConversationPair = { + question: ConversationMessage | null + answer: ConversationMessage | null +} + type SearchContextValue = { // flag for initialize the pathname isPathnameInitialized: boolean @@ -452,6 +461,7 @@ export function Search() { // 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 => ({ @@ -466,6 +476,7 @@ export function Search() { // get and format scores from streaming answer if (!currentAssistantMessage.attachment?.doc && !!answer.attachmentsDoc) { currentAssistantMessage.attachment = { + clientCode: null, doc: answer.attachmentsDoc.map(hit => ({ ...hit.doc, @@ -618,7 +629,8 @@ export function Search() { content: '', attachment: { code: null, - doc: null + doc: null, + clientCode: null }, error: undefined } @@ -730,6 +742,25 @@ export function Search() { 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' } @@ -788,35 +819,34 @@ export function Search() {
- {/* messages */} - {messages.map((message, index) => { - const isLastMessage = index === messages.length - 1 - if (message.role === Role.User) { - return ( - - ) - } else if (message.role === Role.Assistant) { - return ( - <> + {qaPairs.map((pair, index) => { + const isLastMessage = index === qaPairs.length - 1 + if (!pair.question) return null + + return ( + + {!!pair.question && ( + + )} + {!!pair.answer && ( 2} /> - {!isLastMessage && } - - ) - } else { - return null - } + )} + {!isLastMessage && } + + ) })}
@@ -949,12 +979,6 @@ export function Search() { ) } -const setThreadPersistedMutation = graphql(/* GraphQL */ ` - mutation SetThreadPersisted($threadId: ID!) { - setThreadPersisted(threadId: $threadId) - } -`) - const updateThreadMessageMutation = graphql(/* GraphQL */ ` mutation UpdateThreadMessage($input: UpdateMessageInput!) { updateThreadMessage(input: $input) diff --git a/ee/tabby-ui/components/chat/chat-panel.tsx b/ee/tabby-ui/components/chat/chat-panel.tsx index faf93ef75b17..8f1d73ebe030 100644 --- a/ee/tabby-ui/components/chat/chat-panel.tsx +++ b/ee/tabby-ui/components/chat/chat-panel.tsx @@ -1,18 +1,27 @@ -import React, { RefObject } from 'react' +import React, { RefObject, useMemo, useState } from 'react' +import slugify from '@sindresorhus/slugify' import type { UseChatHelpers } from 'ai/react' import { AnimatePresence, motion } from 'framer-motion' +import { compact } from 'lodash-es' +import { toast } from 'sonner' import type { Context } from 'tabby-chat-panel' +import { SLUG_TITLE_MAX_LENGTH } from '@/lib/constants' +import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard' import { updateEnableActiveSelection } from '@/lib/stores/chat-actions' import { useChatStore } from '@/lib/stores/chat-store' -import { cn } from '@/lib/utils' +import { useMutation } from '@/lib/tabby/gql' +import { setThreadPersistedMutation } from '@/lib/tabby/query' +import { cn, getTitleFromMessages } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { + IconCheck, IconEye, IconEyeOff, IconRefresh, IconRemove, + IconShare, IconStop, IconTrash } from '@/components/ui/icons' @@ -51,17 +60,64 @@ function ChatPanelRenderer( ) { const promptFormRef = React.useRef(null) const { + threadId, container, onClearMessages, qaPairs, isLoading, relevantContext, removeRelevantContext, - activeSelection + activeSelection, + onCopyContent } = React.useContext(ChatContext) const enableActiveSelection = useChatStore( state => state.enableActiveSelection ) + const [persisting, setPerisiting] = useState(false) + const slugWithThreadId = useMemo(() => { + if (!threadId) return '' + const content = qaPairs[0]?.user.message + if (!content) return threadId + + const title = getTitleFromMessages([], content, { + maxLength: SLUG_TITLE_MAX_LENGTH + }) + const slug = slugify(title) + const slugWithThreadId = compact([slug, threadId]).join('-') + return slugWithThreadId + }, [qaPairs[0]?.user.message, threadId]) + + const setThreadPersisted = useMutation(setThreadPersistedMutation, { + onError(err) { + toast.error(err.message) + } + }) + + const { isCopied, copyToClipboard } = useCopyToClipboard({ + timeout: 2000, + onCopyContent + }) + + const handleShareThread = async () => { + if (!threadId) return + if (isCopied || persisting) return + + try { + setPerisiting(true) + const result = await setThreadPersisted({ threadId }) + if (!result?.data?.setThreadPersisted) { + toast.error(result?.error?.message || 'Failed to share') + } else { + let url = new URL(window.location.origin) + url.pathname = `/search/${slugWithThreadId}` + + copyToClipboard(url.toString()) + } + } catch (e) { + } finally { + setPerisiting(false) + } + } React.useImperativeHandle( ref, @@ -91,14 +147,24 @@ function ChatPanelRenderer( ) : ( qaPairs?.length > 0 && ( - + <> + + + ) )} {qaPairs?.length > 0 && ( diff --git a/ee/tabby-ui/components/chat/chat.tsx b/ee/tabby-ui/components/chat/chat.tsx index 96b8e27f2a1d..c86caee905a1 100644 --- a/ee/tabby-ui/components/chat/chat.tsx +++ b/ee/tabby-ui/components/chat/chat.tsx @@ -32,6 +32,7 @@ import { EmptyScreen } from './empty-screen' import { QuestionAnswerList } from './question-answer' type ChatContextValue = { + threadId: string | undefined isLoading: boolean qaPairs: QuestionAnswerPair[] handleMessageAction: ( @@ -521,6 +522,7 @@ function ChatRenderer( return ( void highlightIndex?: number | undefined showExternalLink: boolean + showClientCodeIcon: boolean } export const CodeReferences = forwardRef< @@ -39,21 +41,23 @@ export const CodeReferences = forwardRef< ( { contexts, - userContexts, + clientContexts, className, triggerClassname, onContextClick, enableTooltip, onTooltipClick, highlightIndex, - showExternalLink + showExternalLink, + showClientCodeIcon, + isInEditor }, ref ) => { - const totalContextLength = (userContexts?.length || 0) + contexts.length + const totalContextLength = (clientContexts?.length || 0) + contexts.length const isMultipleReferences = totalContextLength > 1 const serverCtxLen = contexts?.length ?? 0 - const clientCtxLen = userContexts?.length ?? 0 + const clientCtxLen = clientContexts?.length ?? 0 const ctxLen = serverCtxLen + clientCtxLen const [accordionValue, setAccordionValue] = useState( ctxLen <= 5 ? 'references' : undefined @@ -86,13 +90,15 @@ export const CodeReferences = forwardRef< }`} - {userContexts?.map((item, index) => { + {clientContexts?.map((item, index) => { return ( onContextClick?.(ctx, true)} isHighlighted={highlightIndex === index} + clickable={isInEditor || !!item.git_url} + showClientCodeIcon={showClientCodeIcon} /> ) })} @@ -106,7 +112,7 @@ export const CodeReferences = forwardRef< onTooltipClick={onTooltipClick} showExternalLinkIcon={showExternalLink} isHighlighted={ - highlightIndex === index + (userContexts?.length || 0) + highlightIndex === index + (clientContexts?.length || 0) } /> ) @@ -126,6 +132,7 @@ function ContextItem({ enableTooltip, onTooltipClick, showExternalLinkIcon, + showClientCodeIcon, isHighlighted }: { context: RelevantCodeContext @@ -134,6 +141,7 @@ function ContextItem({ enableTooltip?: boolean onTooltipClick?: () => void showExternalLinkIcon?: boolean + showClientCodeIcon?: boolean isHighlighted?: boolean }) { const [tooltipOpen, setTooltipOpen] = useState(false) @@ -182,6 +190,9 @@ function ContextItem({ )} {path} + {showClientCodeIcon && ( + + )} {showExternalLinkIcon && ( )} diff --git a/ee/tabby-ui/components/chat/question-answer.tsx b/ee/tabby-ui/components/chat/question-answer.tsx index c5612862adb7..7848aec81a91 100644 --- a/ee/tabby-ui/components/chat/question-answer.tsx +++ b/ee/tabby-ui/components/chat/question-answer.tsx @@ -296,30 +296,33 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { const attachmentDocsLen = 0 - const attachmentCode: Array = useMemo(() => { - const formatedClientAttachmentCode = - clientCode?.map(o => ({ - content: o.content, - filepath: o.filepath, - gitUrl: o.git_url, - startLine: o.range.start, - language: filename2prism(o.filepath ?? '')[0], - isClient: true - })) ?? [] - const formatedServerAttachmentCode = - serverCode?.map(o => ({ - content: o.content, - filepath: o.filepath, - gitUrl: o.git_url, - startLine: o.range.start, - language: filename2prism(o.filepath ?? '')[0], - isClient: false - })) ?? [] - return compact([ - ...formatedClientAttachmentCode, - ...formatedServerAttachmentCode - ]) - }, [clientCode, serverCode]) + const attachmentClientCode: Array> = + useMemo(() => { + const formatedAttachmentClientCode = + clientCode?.map(o => ({ + content: o.content, + filepath: o.filepath, + gitUrl: o.git_url, + startLine: o.range.start, + language: filename2prism(o.filepath ?? '')[0], + isClient: true + })) ?? [] + return formatedAttachmentClientCode + }, [clientCode]) + + const attachmentCode: Array> = + useMemo(() => { + const formatedServerAttachmentCode = + serverCode?.map(o => ({ + content: o.content, + filepath: o.filepath, + gitUrl: o.git_url, + startLine: o.range.start, + language: filename2prism(o.filepath ?? '')[0], + isClient: false + })) ?? [] + return compact([...formatedServerAttachmentCode]) + }, [serverCode]) const onCodeCitationMouseEnter = (index: number) => { setRelevantCodeHighlightIndex(index - 1 - (attachmentDocsLen || 0)) @@ -374,7 +377,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) {
{ onNavigateToContext?.(ctx, { openInEditor: isInWorkspace @@ -382,6 +385,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { }} // When onApplyInEditor is null, it means isInEditor === false, thus there's no need to showExternalLink showExternalLink={!!onApplyInEditor} + showClientCodeIcon={!onApplyInEditor} highlightIndex={relevantCodeHighlightIndex} triggerClassname="md:pt-0" /> @@ -393,6 +397,7 @@ function AssistantMessageCard(props: AssistantMessageCardProps) { message={message.message} onApplyInEditor={onApplyInEditor} onCopyContent={onCopyContent} + attachmentClientCode={attachmentClientCode} attachmentCode={attachmentCode} onCodeCitationClick={onCodeCitationClick} onCodeCitationMouseEnter={onCodeCitationMouseEnter} diff --git a/ee/tabby-ui/components/message-markdown/index.tsx b/ee/tabby-ui/components/message-markdown/index.tsx index 8cfa7fa4f970..a0955880af8e 100644 --- a/ee/tabby-ui/components/message-markdown/index.tsx +++ b/ee/tabby-ui/components/message-markdown/index.tsx @@ -8,7 +8,11 @@ import { marked } from 'marked' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' -import { ContextInfo, Maybe } from '@/lib/gql/generates/graphql' +import { + ContextInfo, + Maybe, + MessageAttachmentClientCode +} from '@/lib/gql/generates/graphql' import { AttachmentCodeItem, AttachmentDocItem } from '@/lib/types' import { cn, getContent } from '@/lib/utils' import { CodeBlock, CodeBlockProps } from '@/components/ui/codeblock' @@ -43,7 +47,7 @@ type RelevantDocItem = { type RelevantCodeItem = { type: 'code' - data: AttachmentCodeItem + data: AttachmentCodeItem | MessageAttachmentClientCode isClient?: boolean } @@ -65,6 +69,7 @@ export interface MessageMarkdownProps { headline?: boolean attachmentDocs?: Maybe> attachmentCode?: Maybe> + attachmentClientCode?: Maybe> onCopyContent?: ((value: string) => void) | undefined onApplyInEditor?: ( content: string, @@ -104,6 +109,7 @@ export function MessageMarkdown({ message, headline = false, attachmentDocs, + attachmentClientCode, attachmentCode, onApplyInEditor, onCopyContent, @@ -120,13 +126,20 @@ export function MessageMarkdown({ type: 'doc', data: item })) ?? [] + + const clientCode: MessageAttachments = + attachmentClientCode?.map(item => ({ + type: 'code', + data: item + })) ?? [] + const code: MessageAttachments = attachmentCode?.map(item => ({ type: 'code', data: item })) ?? [] - return compact([...docs, ...code]) - }, [attachmentDocs, attachmentCode]) + return compact([...docs, ...clientCode, ...code]) + }, [attachmentDocs, attachmentClientCode, attachmentCode]) const processMessagePlaceholder = (text: string) => { const elements: React.ReactNode[] = [] diff --git a/ee/tabby-ui/components/ui/icons.tsx b/ee/tabby-ui/components/ui/icons.tsx index 6a118e352a51..40e806a33717 100644 --- a/ee/tabby-ui/components/ui/icons.tsx +++ b/ee/tabby-ui/components/ui/icons.tsx @@ -19,6 +19,7 @@ import { Eye, EyeOff, Files, + FileSearch2, FileText, Filter, GitFork, @@ -1729,6 +1730,13 @@ function IconGitMerge({ return } +function IconFileSearch2({ + className, + ...props +}: React.ComponentProps) { + return +} + export { IconEdit, IconNextChat, @@ -1839,5 +1847,6 @@ export { IconEyeOff, IconCircleDot, IconGitPullRequest, - IconGitMerge + IconGitMerge, + IconFileSearch2 } diff --git a/ee/tabby-ui/lib/tabby/query.ts b/ee/tabby-ui/lib/tabby/query.ts index 8fe876ac999a..10375f3025da 100644 --- a/ee/tabby-ui/lib/tabby/query.ts +++ b/ee/tabby-ui/lib/tabby/query.ts @@ -445,3 +445,9 @@ export const listThreadMessages = graphql(/* GraphQL */ ` } } `) + +export const setThreadPersistedMutation = graphql(/* GraphQL */ ` + mutation SetThreadPersisted($threadId: ID!) { + setThreadPersisted(threadId: $threadId) + } +`) diff --git a/ee/tabby-ui/lib/types/chat.ts b/ee/tabby-ui/lib/types/chat.ts index eef776eac70c..a1c0caf291ac 100644 --- a/ee/tabby-ui/lib/types/chat.ts +++ b/ee/tabby-ui/lib/types/chat.ts @@ -102,6 +102,7 @@ export type AttachmentCodeItem = isClient?: boolean extra?: { scores?: MessageCodeSearchHit['scores'] } } + // for rendering, including score export type AttachmentDocItem = ArrayElementType['doc'] & { diff --git a/ee/tabby-ui/lib/utils/index.ts b/ee/tabby-ui/lib/utils/index.ts index 566806378b80..b68738695766 100644 --- a/ee/tabby-ui/lib/utils/index.ts +++ b/ee/tabby-ui/lib/utils/index.ts @@ -5,6 +5,8 @@ import { twMerge } from 'tailwind-merge' import { AttachmentCodeItem, AttachmentDocItem } from '@/lib/types' +import { Maybe } from '../gql/generates/graphql' + export * from './chat' export function cn(...inputs: ClassValue[]) { @@ -104,7 +106,10 @@ export function formatLineHashForCodeBrowser( ).join('-') } -export function getRangeFromAttachmentCode(code: AttachmentCodeItem) { +export function getRangeFromAttachmentCode(code: { + startLine?: Maybe + content: string +}) { const startLine = code?.startLine ?? 0 const lineCount = code?.content.split('\n').length const endLine = startLine + lineCount - 1