* fix: avatar assistants render * fix: delete assistant * Update web-app/src/containers/dialogs/AddEditAssistant.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
434 lines
16 KiB
TypeScript
434 lines
16 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) {
|
||
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
|
||
sendMessage(toSendMessage.content?.[0]?.text?.value || '')
|
||
}
|
||
}, [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 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 className="w-full">
|
||
<div className="flex justify-end w-full h-full text-start break-words whitespace-normal">
|
||
<div className="bg-main-view-fg/4 relative text-main-view-fg p-2 rounded-md inline-block max-w-[80%] ">
|
||
<div className="select-text">
|
||
<RenderMarkdown
|
||
content={item.content?.[0].text.value}
|
||
components={linkComponents}
|
||
isUser
|
||
/>
|
||
</div>
|
||
</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>
|
||
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
|
||
<IconPencil size={16} />
|
||
</div>
|
||
</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">
|
||
{assistant?.avatar && (
|
||
<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}
|
||
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={() => {
|
||
removeMessage()
|
||
}}
|
||
>
|
||
<IconTrash size={16} />
|
||
</button>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>Delete</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
<Dialog>
|
||
<DialogTrigger>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
|
||
<IconInfoCircle size={16} />
|
||
</div>
|
||
</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 !text-sm"
|
||
/>
|
||
</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>
|
||
)
|
||
}
|
||
)
|