jan/web-app/src/containers/ThreadContent.tsx
Faisal Amir dfe15fac32
enhancement: built-in custom emoji and show metadata message (#5085)
* enhancement: built-in custom emoji and show metadata message

* chore: seperate render avatar as component

* fix: avatar on assistant screen
2025-05-23 15:37:47 +07:00

399 lines
14 KiB
TypeScript

import { ThreadMessage } from '@janhq/core'
import { RenderMarkdown } from './RenderMarkdown'
import { 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 { toast } from 'sonner'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatDate } from '@/utils/formatDate'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
className="flex items-center gap-1 hover:text-accent transition-colors group relative cursor-pointer"
onClick={handleCopy}
>
{copied ? (
<>
<IconCopyCheck size={16} className="text-accent" />
<span className="opacity-100">Copied!</span>
</>
) : (
<Tooltip>
<TooltipTrigger asChild>
<IconCopy size={16} />
</TooltipTrigger>
<TooltipContent>
<p>Copy</p>
</TooltipContent>
</Tooltip>
)}
</button>
)
}
// 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
}
) => {
const [message, setMessage] = useState(item.content?.[0]?.text?.value || '')
// Use useMemo to stabilize the components prop
const linkComponents = useMemo(
() => ({
a: ({ ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer" />
),
}),
[]
)
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(() => {
const isThinking = text.includes('<think>') && !text.includes('</think>')
if (isThinking) return { reasoningSegment: text, textSegment: '' }
const match = text.match(/<think>([\s\S]*?)<\/think>/)
if (match?.index === undefined)
return { reasoningSegment: undefined, textSegment: text }
const splitIndex = match.index + match[0].length
return {
reasoningSegment: text.slice(0, splitIndex),
textSegment: text.slice(splitIndex),
}
}, [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)
sendMessage(toSendMessage.content?.[0]?.text?.value || '')
}, [deleteMessage, getMessages, item, sendMessage])
const editMessage = useCallback(
(messageId: string) => {
const threadMessages = getMessages(item.thread_id)
const index = threadMessages.findIndex((msg) => msg.id === messageId)
if (index === -1) return
// Delete all messages after the edited message
for (let i = threadMessages.length - 1; i >= index; i--) {
deleteMessage(threadMessages[i].thread_id, threadMessages[i].id)
}
sendMessage(message)
},
[deleteMessage, getMessages, item.thread_id, message, sendMessage]
)
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 (
<Fragment>
{item.content?.[0]?.text && item.role === 'user' && (
<div>
<div className="flex justify-end w-full">
<div className="bg-main-view-fg/4 text-main-view-fg p-2 rounded-md inline-block">
<p className="select-text">{item.content?.[0].text.value}</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconPencil size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Edit</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Message</DialogTitle>
<Textarea
value={message}
onChange={(e) => {
setMessage(e.target.value)
}}
className="mt-2 resize-none"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button
disabled={!message}
onClick={() => {
editMessage(item.id)
toast.success('Edit Message', {
id: 'edit-message',
description:
'Message edited successfully. Please wait for the model to respond.',
})
}}
>
Save
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
deleteMessage(item.thread_id, item.id)
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
</div>
</div>
)}
{item.content?.[0]?.text && item.role !== 'user' && (
<>
{item.showAssistant && (
<div className="flex items-center gap-2 mb-3 text-main-view-fg/60">
<div className="flex items-center gap-2 size-8 rounded-md justify-center border border-main-view-fg/10 bg-main-view-fg/5 p-1">
<AvatarEmoji
avatar={assistant?.avatar}
fallback="👋"
imageClassName="w-6 h-6 object-contain"
textClassName="text-base"
/>
</div>
<div className="flex flex-col">
<span className="text-main-view-fg font-medium">
{assistant?.name || 'Jan'}
</span>
{item?.created_at && item?.created_at !== 0 && (
<span className="text-xs mt-0.5">
{formatDate(item?.created_at)}
</span>
)}
</div>
</div>
)}
{reasoningSegment && (
<ThinkingBlock
id={item.index ?? Number(item.id)}
text={reasoningSegment}
/>
)}
<RenderMarkdown content={textSegment} components={linkComponents} />
{isToolCalls && item.metadata?.tool_calls ? (
<>
{(item.metadata.tool_calls as ToolCall[]).map((toolCall) => (
<ToolCallBlock
id={toolCall.tool?.id ?? 0}
name={toolCall.tool?.function?.name ?? ''}
key={toolCall.tool?.id}
result={JSON.stringify(toolCall.response)}
loading={toolCall.state === 'pending'}
/>
))}
</>
) : null}
{!isToolCalls && (
<div className="flex items-center gap-2 mt-2 text-main-view-fg/60 text-xs">
<div
className={cn(
'flex items-center gap-2',
item.isLastMessage &&
streamingContent &&
'opacity-0 visinility-hidden pointer-events-none'
)}
>
<CopyButton text={item.content?.[0]?.text.value || ''} />
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
deleteMessage(item.thread_id, item.id)
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Metadata</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Message Metadata</DialogTitle>
<div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={JSON.stringify(
item.metadata || {},
null,
2
)}
language="json"
readOnly
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
height: '100%',
}}
className="w-full h-full"
/>
</div>
</div>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
{item.isLastMessage && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={regenerate}
>
<IconRefresh size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Regenerate</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</>
)}
{item.type === 'image_url' && image && (
<div>
<img
src={image.url}
alt={image.detail || 'Thread image'}
className="max-w-full rounded-md"
/>
{image.detail && <p className="text-sm mt-1">{image.detail}</p>}
</div>
)}
</Fragment>
)
}
)