/* eslint-disable @typescript-eslint/no-explicit-any */ import { ThreadMessage } from '@janhq/core' import { RenderMarkdown } from './RenderMarkdown' import React, { Fragment, memo, useCallback, useMemo, useState } from 'react' import { IconCopy, IconCopyCheck, IconRefresh, IconTrash, IconPencil, IconInfoCircle, } from '@tabler/icons-react' import { useAppState } from '@/hooks/useAppState' import { cn } from '@/lib/utils' import { useMessages } from '@/hooks/useMessages' import ThinkingBlock from '@/containers/ThinkingBlock' import ToolCallBlock from '@/containers/ToolCallBlock' import { useChat } from '@/hooks/useChat' import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip' import { formatDate } from '@/utils/formatDate' import { AvatarEmoji } from '@/containers/AvatarEmoji' import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator' import CodeEditor from '@uiw/react-textarea-code-editor' import '@uiw/react-textarea-code-editor/dist.css' import { useTranslation } from '@/i18n/react-i18next-compat' import { useModelProvider } from '@/hooks/useModelProvider' const CopyButton = ({ text }: { text: string }) => { const [copied, setCopied] = useState(false) const { t } = useTranslation() const handleCopy = () => { navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } return ( {copied ? ( <> {t('copied')} > ) : ( {t('copy')} )} ) } const EditDialog = ({ message, setMessage, }: { message: string setMessage: (message: string) => void }) => { const { t } = useTranslation() const [draft, setDraft] = useState(message) const handleSave = () => { if (draft !== message) { setMessage(draft) } } return ( {t('edit')} {t('common:dialogs.editMessage.title')} setDraft(e.target.value)} className="mt-2 resize-none w-full" onKeyDown={(e) => { // Prevent key from being captured by parent components e.stopPropagation() }} /> Cancel Save ) } // Use memo to prevent unnecessary re-renders, but allow re-renders when props change export const ThreadContent = memo( ( item: ThreadMessage & { isLastMessage?: boolean index?: number showAssistant?: boolean streamTools?: any contextOverflowModal?: React.ReactNode | null updateMessage?: (item: ThreadMessage, message: string) => void } ) => { const { t } = useTranslation() const { selectedModel } = useModelProvider() // Use useMemo to stabilize the components prop const linkComponents = useMemo( () => ({ a: ({ ...props }) => ( ), }), [] ) const image = useMemo(() => item.content?.[0]?.image_url, [item]) const { streamingContent } = useAppState() const text = useMemo( () => item.content.find((e) => e.type === 'text')?.text?.value ?? '', [item.content] ) const { reasoningSegment, textSegment } = useMemo(() => { // Check for thinking formats const hasThinkTag = text.includes('') && !text.includes('') const hasAnalysisChannel = text.includes('<|channel|>analysis<|message|>') && !text.includes('<|start|>assistant<|channel|>final<|message|>') if (hasThinkTag || hasAnalysisChannel) return { reasoningSegment: text, textSegment: '' } // Check for completed think tag format const thinkMatch = text.match(/([\s\S]*?)<\/think>/) if (thinkMatch?.index !== undefined) { const splitIndex = thinkMatch.index + thinkMatch[0].length return { reasoningSegment: text.slice(0, splitIndex), textSegment: text.slice(splitIndex), } } // Check for completed analysis channel format const analysisMatch = text.match( /<\|channel\|>analysis<\|message\|>([\s\S]*?)<\|start\|>assistant<\|channel\|>final<\|message\|>/ ) if (analysisMatch?.index !== undefined) { const splitIndex = analysisMatch.index + analysisMatch[0].length return { reasoningSegment: text.slice(0, splitIndex), textSegment: text.slice(splitIndex), } } return { reasoningSegment: undefined, textSegment: text } }, [text]) const { getMessages, deleteMessage } = useMessages() const { sendMessage } = useChat() const regenerate = useCallback(() => { // Only regenerate assistant message is allowed deleteMessage(item.thread_id, item.id) const threadMessages = getMessages(item.thread_id) let toSendMessage = threadMessages.pop() while (toSendMessage && toSendMessage?.role !== 'user') { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') toSendMessage = threadMessages.pop() } if (toSendMessage) { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') // Extract text content and any attachments const textContent = toSendMessage.content?.find((c) => c.type === 'text')?.text?.value || '' const attachments = toSendMessage.content ?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false) .map((c) => { if (c.type === 'image_url' && c.image_url?.url) { const url = c.image_url.url const [mimeType, base64] = url .replace('data:', '') .split(';base64,') return { name: 'image', // We don't have the original filename type: mimeType, size: 0, // We don't have the original size base64: base64, dataUrl: url, } } return null }) .filter(Boolean) as Array<{ name: string type: string size: number base64: string dataUrl: string }> sendMessage(textContent, true, attachments) } }, [deleteMessage, getMessages, item, sendMessage]) const removeMessage = useCallback(() => { if ( item.index !== undefined && (item.role === 'assistant' || item.role === 'tool') ) { const threadMessages = getMessages(item.thread_id).slice( 0, item.index + 1 ) let toSendMessage = threadMessages.pop() while (toSendMessage && toSendMessage?.role !== 'user') { deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') toSendMessage = threadMessages.pop() // Stop deletion when encountering an assistant message that isn’t a tool call if ( toSendMessage && toSendMessage.role === 'assistant' && !('tool_calls' in (toSendMessage.metadata ?? {})) ) break } } else { deleteMessage(item.thread_id, item.id) } }, [deleteMessage, getMessages, item]) const isToolCalls = item.metadata && 'tool_calls' in item.metadata && Array.isArray(item.metadata.tool_calls) && item.metadata.tool_calls.length const assistant = item.metadata?.assistant as | { avatar?: React.ReactNode; name?: React.ReactNode } | undefined return ( {item.role === 'user' && ( {/* Render attachments above the message bubble */} {item.content?.some( (c) => (c.type === 'image_url' && c.image_url?.url) || false ) && ( {item.content ?.filter( (c) => (c.type === 'image_url' && c.image_url?.url) || false ) .map((contentPart, index) => { // Handle images if ( contentPart.type === 'image_url' && contentPart.image_url?.url ) { return ( ) } return null })} )} {/* Render text content in the message bubble */} {item.content?.some((c) => c.type === 'text' && c.text?.value) && ( {item.content ?.filter((c) => c.type === 'text' && c.text?.value) .map((contentPart, index) => ( ))} )} c.type === 'text')?.text?.value || '' } setMessage={(message) => { if (item.updateMessage) { item.updateMessage(item, message) } }} /> { deleteMessage(item.thread_id, item.id) }} > {t('delete')} )} {item.content?.[0]?.text && item.role !== 'user' && ( <> {item.showAssistant && ( {assistant?.avatar && ( )} {assistant?.name || 'Jan'} {item?.created_at && item?.created_at !== 0 && ( {formatDate(item?.created_at)} )} )} {reasoningSegment && ( )} ', '')} components={linkComponents} /> {isToolCalls && item.metadata?.tool_calls ? ( <> {(item.metadata.tool_calls as ToolCall[]).map((toolCall) => ( ))} > ) : null} {!isToolCalls && ( item.updateMessage && item.updateMessage(item, message) } /> { removeMessage() }} > {t('delete')} {t('metadata')} {t('common:dialogs.messageMetadata.title')} {item.isLastMessage && selectedModel && ( {t('regenerate')} )} )} > )} {item.type === 'image_url' && image && ( {image.detail && {image.detail}} )} {item.contextOverflowModal && item.contextOverflowModal} ) } )
{t('copy')}
{t('edit')}
{t('delete')}
{t('metadata')}
{t('regenerate')}
{image.detail}