chatbot-ui/components/messages/message.tsx

446 lines
14 KiB
TypeScript

import { useChatHandler } from "@/components/chat/chat-hooks/use-chat-handler"
import { ChatbotUIContext } from "@/context/context"
import { LLM_LIST } from "@/lib/models/llm/llm-list"
import { cn } from "@/lib/utils"
import { Tables } from "@/supabase/types"
import { LLM, LLMID, MessageImage, ModelProvider } from "@/types"
import {
IconBolt,
IconCaretDownFilled,
IconCaretRightFilled,
IconCircleFilled,
IconFileText,
IconMoodSmile,
IconPencil
} from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useEffect, useRef, useState } from "react"
import { ModelIcon } from "../models/model-icon"
import { Button } from "../ui/button"
import { FileIcon } from "../ui/file-icon"
import { FilePreview } from "../ui/file-preview"
import { TextareaAutosize } from "../ui/textarea-autosize"
import { WithTooltip } from "../ui/with-tooltip"
import { MessageActions } from "./message-actions"
import { MessageMarkdown } from "./message-markdown"
const ICON_SIZE = 32
interface MessageProps {
message: Tables<"messages">
fileItems: Tables<"file_items">[]
isEditing: boolean
isLast: boolean
onStartEdit: (message: Tables<"messages">) => void
onCancelEdit: () => void
onSubmitEdit: (value: string, sequenceNumber: number) => void
}
export const Message: FC<MessageProps> = ({
message,
fileItems,
isEditing,
isLast,
onStartEdit,
onCancelEdit,
onSubmitEdit
}) => {
const {
assistants,
profile,
isGenerating,
setIsGenerating,
firstTokenReceived,
availableLocalModels,
availableOpenRouterModels,
chatMessages,
selectedAssistant,
chatImages,
assistantImages,
toolInUse,
files,
models
} = useContext(ChatbotUIContext)
const { handleSendMessage } = useChatHandler()
const editInputRef = useRef<HTMLTextAreaElement>(null)
const [isHovering, setIsHovering] = useState(false)
const [editedMessage, setEditedMessage] = useState(message.content)
const [showImagePreview, setShowImagePreview] = useState(false)
const [selectedImage, setSelectedImage] = useState<MessageImage | null>(null)
const [showFileItemPreview, setShowFileItemPreview] = useState(false)
const [selectedFileItem, setSelectedFileItem] =
useState<Tables<"file_items"> | null>(null)
const [viewSources, setViewSources] = useState(false)
const handleCopy = () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(message.content)
} else {
const textArea = document.createElement("textarea")
textArea.value = message.content
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
}
}
const handleSendEdit = () => {
onSubmitEdit(editedMessage, message.sequence_number)
onCancelEdit()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
if (isEditing && event.key === "Enter" && event.metaKey) {
handleSendEdit()
}
}
const handleRegenerate = async () => {
setIsGenerating(true)
await handleSendMessage(
editedMessage || chatMessages[chatMessages.length - 2].message.content,
chatMessages,
true
)
}
const handleStartEdit = () => {
onStartEdit(message)
}
useEffect(() => {
setEditedMessage(message.content)
if (isEditing && editInputRef.current) {
const input = editInputRef.current
input.focus()
input.setSelectionRange(input.value.length, input.value.length)
}
}, [isEditing])
const MODEL_DATA = [
...models.map(model => ({
modelId: model.model_id as LLMID,
modelName: model.name,
provider: "custom" as ModelProvider,
hostedId: model.id,
platformLink: "",
imageInput: false
})),
...LLM_LIST,
...availableLocalModels,
...availableOpenRouterModels
].find(llm => llm.modelId === message.model) as LLM
const messageAssistantImage = assistantImages.find(
image => image.assistantId === message.assistant_id
)?.base64
const selectedAssistantImage = assistantImages.find(
image => image.path === selectedAssistant?.image_path
)?.base64
const modelDetails = LLM_LIST.find(model => model.modelId === message.model)
const fileAccumulator: Record<
string,
{
id: string
name: string
count: number
type: string
description: string
}
> = {}
const fileSummary = fileItems.reduce((acc, fileItem) => {
const parentFile = files.find(file => file.id === fileItem.file_id)
if (parentFile) {
if (!acc[parentFile.id]) {
acc[parentFile.id] = {
id: parentFile.id,
name: parentFile.name,
count: 1,
type: parentFile.type,
description: parentFile.description
}
} else {
acc[parentFile.id].count += 1
}
}
return acc
}, fileAccumulator)
return (
<div
className={cn(
"flex w-full justify-center",
message.role === "user" ? "" : "bg-secondary"
)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onKeyDown={handleKeyDown}
>
<div className="relative flex w-full flex-col p-6 sm:w-[550px] sm:px-0 md:w-[650px] lg:w-[650px] xl:w-[700px]">
<div className="absolute right-5 top-7 sm:right-0">
<MessageActions
onCopy={handleCopy}
onEdit={handleStartEdit}
isAssistant={message.role === "assistant"}
isLast={isLast}
isEditing={isEditing}
isHovering={isHovering}
onRegenerate={handleRegenerate}
/>
</div>
<div className="space-y-3">
{message.role === "system" ? (
<div className="flex items-center space-x-4">
<IconPencil
className="border-primary bg-primary text-secondary rounded border-DEFAULT p-1"
size={ICON_SIZE}
/>
<div className="text-lg font-semibold">Prompt</div>
</div>
) : (
<div className="flex items-center space-x-3">
{message.role === "assistant" ? (
messageAssistantImage ? (
<Image
style={{
width: `${ICON_SIZE}px`,
height: `${ICON_SIZE}px`
}}
className="rounded"
src={messageAssistantImage}
alt="assistant image"
height={ICON_SIZE}
width={ICON_SIZE}
/>
) : (
<WithTooltip
display={<div>{MODEL_DATA?.modelName}</div>}
trigger={
<ModelIcon
provider={modelDetails?.provider || "custom"}
height={ICON_SIZE}
width={ICON_SIZE}
/>
}
/>
)
) : profile?.image_url ? (
<Image
className={`size-[32px] rounded`}
src={profile?.image_url}
height={32}
width={32}
alt="user image"
/>
) : (
<IconMoodSmile
className="bg-primary text-secondary border-primary rounded border-DEFAULT p-1"
size={ICON_SIZE}
/>
)}
<div className="font-semibold">
{message.role === "assistant"
? message.assistant_id
? assistants.find(
assistant => assistant.id === message.assistant_id
)?.name
: selectedAssistant
? selectedAssistant?.name
: MODEL_DATA?.modelName
: profile?.display_name ?? profile?.username}
</div>
</div>
)}
{!firstTokenReceived &&
isGenerating &&
isLast &&
message.role === "assistant" ? (
<>
{(() => {
switch (toolInUse) {
case "none":
return (
<IconCircleFilled className="animate-pulse" size={20} />
)
case "retrieval":
return (
<div className="flex animate-pulse items-center space-x-2">
<IconFileText size={20} />
<div>Searching files...</div>
</div>
)
default:
return (
<div className="flex animate-pulse items-center space-x-2">
<IconBolt size={20} />
<div>Using {toolInUse}...</div>
</div>
)
}
})()}
</>
) : isEditing ? (
<TextareaAutosize
textareaRef={editInputRef}
className="text-md"
value={editedMessage}
onValueChange={setEditedMessage}
maxRows={20}
/>
) : (
<MessageMarkdown content={message.content} />
)}
</div>
{fileItems.length > 0 && (
<div className="border-primary mt-6 border-t pt-4 font-bold">
{!viewSources ? (
<div
className="flex cursor-pointer items-center text-lg hover:opacity-50"
onClick={() => setViewSources(true)}
>
{fileItems.length}
{fileItems.length > 1 ? " Sources " : " Source "}
from {Object.keys(fileSummary).length}{" "}
{Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "}
<IconCaretRightFilled className="ml-1" />
</div>
) : (
<>
<div
className="flex cursor-pointer items-center text-lg hover:opacity-50"
onClick={() => setViewSources(false)}
>
{fileItems.length}
{fileItems.length > 1 ? " Sources " : " Source "}
from {Object.keys(fileSummary).length}{" "}
{Object.keys(fileSummary).length > 1 ? "Files" : "File"}{" "}
<IconCaretDownFilled className="ml-1" />
</div>
<div className="mt-3 space-y-4">
{Object.values(fileSummary).map((file, index) => (
<div key={index}>
<div className="flex items-center space-x-2">
<div>
<FileIcon type={file.type} />
</div>
<div className="truncate">{file.name}</div>
</div>
{fileItems
.filter(fileItem => {
const parentFile = files.find(
parentFile => parentFile.id === fileItem.file_id
)
return parentFile?.id === file.id
})
.map((fileItem, index) => (
<div
key={index}
className="ml-8 mt-1.5 flex cursor-pointer items-center space-x-2 hover:opacity-50"
onClick={() => {
setSelectedFileItem(fileItem)
setShowFileItemPreview(true)
}}
>
<div className="text-sm font-normal">
<span className="mr-1 text-lg font-bold">-</span>{" "}
{fileItem.content.substring(0, 200)}...
</div>
</div>
))}
</div>
))}
</div>
</>
)}
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
{message.image_paths.map((path, index) => {
const item = chatImages.find(image => image.path === path)
return (
<Image
key={index}
className="cursor-pointer rounded hover:opacity-50"
src={path.startsWith("data") ? path : item?.base64}
alt="message image"
width={300}
height={300}
onClick={() => {
setSelectedImage({
messageId: message.id,
path,
base64: path.startsWith("data") ? path : item?.base64 || "",
url: path.startsWith("data") ? "" : item?.url || "",
file: null
})
setShowImagePreview(true)
}}
loading="lazy"
/>
)
})}
</div>
{isEditing && (
<div className="mt-4 flex justify-center space-x-2">
<Button size="sm" onClick={handleSendEdit}>
Save & Send
</Button>
<Button size="sm" variant="outline" onClick={onCancelEdit}>
Cancel
</Button>
</div>
)}
</div>
{showImagePreview && selectedImage && (
<FilePreview
type="image"
item={selectedImage}
isOpen={showImagePreview}
onOpenChange={(isOpen: boolean) => {
setShowImagePreview(isOpen)
setSelectedImage(null)
}}
/>
)}
{showFileItemPreview && selectedFileItem && (
<FilePreview
type="file_item"
item={selectedFileItem}
isOpen={showFileItemPreview}
onOpenChange={(isOpen: boolean) => {
setShowFileItemPreview(isOpen)
setSelectedFileItem(null)
}}
/>
)}
</div>
)
}