Spaces:
Running
Running
| import { useEffect, useMemo, useRef, useState } from 'react'; | |
| import { CodeBlock } from '@/components/ui/CodeBlock'; | |
| import { | |
| IconCheckCircle, | |
| IconCodeWrap, | |
| IconCrossCircle, | |
| IconLandingAI, | |
| IconListUnordered, | |
| IconTerminalWindow, | |
| IconUser, | |
| IconGlowingDot, | |
| } from '@/components/ui/Icons'; | |
| import { MessageUI } from '@/lib/types'; | |
| import { ChunkBody, CodeResult, formatStreamLogs } from '@/lib/utils/content'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '../ui/Table'; | |
| import { Button } from '../ui/Button'; | |
| import { Dialog, DialogContent, DialogTrigger } from '../ui/Dialog'; | |
| import Img from '../ui/Img'; | |
| import CodeResultDisplay from '../CodeResultDisplay'; | |
| import { useAtom, useSetAtom } from 'jotai'; | |
| import { selectedMessageId } from '@/state/chat'; | |
| import { Message } from '@prisma/client'; | |
| import { Separator } from '../ui/Separator'; | |
| import { cn } from '@/lib/utils'; | |
| export interface ChatMessageProps { | |
| message: Message; | |
| loading?: boolean; | |
| wipAssistantMessage?: MessageUI; | |
| } | |
| export const ChatMessage: React.FC<ChatMessageProps> = ({ | |
| message, | |
| wipAssistantMessage, | |
| loading, | |
| }) => { | |
| const [messageId, setMessageId] = useAtom(selectedMessageId); | |
| const { id, mediaUrl, prompt, response, result } = message; | |
| const [formattedSections, codeResult] = useMemo( | |
| () => formatStreamLogs(response ?? wipAssistantMessage?.content), | |
| [response, wipAssistantMessage?.content], | |
| ); | |
| return ( | |
| <div | |
| className={cn( | |
| 'rounded-md bg-muted border border-muted p-4 pb-5 mb-4 relative', | |
| messageId === id && 'lg:border-primary/50', | |
| result && 'lg:cursor-pointer', | |
| )} | |
| onClick={() => { | |
| if (result) { | |
| setMessageId(id); | |
| } | |
| }} | |
| > | |
| <div className="flex"> | |
| <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background"> | |
| <IconUser /> | |
| </div> | |
| <div className="flex-1 px-1 ml-4 space-y-2 overflow-hidden"> | |
| <p>{prompt}</p> | |
| {mediaUrl && ( | |
| <> | |
| {mediaUrl?.endsWith('.mp4') ? ( | |
| <video src={mediaUrl} controls width={400} height={400} /> | |
| ) : ( | |
| <Dialog> | |
| <DialogTrigger asChild> | |
| <Img src={mediaUrl} alt={mediaUrl} width={300} /> | |
| </DialogTrigger> | |
| <DialogContent className="max-w-5xl"> | |
| <Img src={mediaUrl} alt={mediaUrl} quality={100} /> | |
| </DialogContent> | |
| </Dialog> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {!!formattedSections.length && ( | |
| <> | |
| <Separator className="bg-primary/30 my-4" /> | |
| <div className="flex"> | |
| <div className="flex size-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-primary text-primary-foreground"> | |
| <IconLandingAI /> | |
| </div> | |
| <div className="flex-1 px-1 space-y-4 ml-4 overflow-hidden"> | |
| <Table className="w-[400px]"> | |
| <TableBody> | |
| {formattedSections.map(section => ( | |
| <TableRow | |
| className="border-primary/50 h-[56px]" | |
| key={section.type} | |
| > | |
| <TableCell className="text-center text-webkit-center"> | |
| {ChunkStatusToIconDict[section.status]} | |
| </TableCell> | |
| <TableCell className="font-medium"> | |
| <ChunkTypeToText chunk={section} /> | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| <ChunkPayloadAction payload={section.payload} /> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| {codeResult && ( | |
| <div className="xl:hidden"> | |
| <CodeResultDisplay codeResult={codeResult} /> | |
| </div> | |
| )} | |
| {codeResult && <p>✨ Coding complete</p>} | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| <div | |
| className={cn( | |
| 'w-1/3 h-1 rounded-full overflow-hidden bg-zinc-700 absolute left-1/2 -translate-x-1/2 bottom-2', | |
| loading ? 'opacity-100' : 'opacity-0', | |
| )} | |
| > | |
| <div className="h-full bg-primary animate-progress origin-left-right" /> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const ChunkStatusToIconDict: Record<ChunkBody['status'], React.ReactElement> = { | |
| started: <IconGlowingDot className="bg-yellow-500/80" />, | |
| completed: <IconCheckCircle className="text-green-500" />, | |
| running: <IconGlowingDot className="bg-teal-500/80" />, | |
| failed: <IconCrossCircle className="text-red-500" />, | |
| }; | |
| const ChunkTypeToText: React.FC<{ | |
| chunk: ChunkBody; | |
| }> = ({ chunk }) => { | |
| const { status, type } = chunk; | |
| const [seconds, setSeconds] = useState(0); | |
| const isExecution = type === 'code' && status === 'running'; | |
| useEffect(() => { | |
| if (isExecution) { | |
| const timerId = setInterval(() => { | |
| setSeconds(prevSeconds => Math.round((prevSeconds + 0.2) * 10) / 10); | |
| }, 200); | |
| return () => clearInterval(timerId); | |
| } | |
| }, [isExecution]); | |
| if (type === 'plans') return <p>Creating instructions</p>; | |
| if (type === 'tools') return <p>Retrieving tools</p>; | |
| if (type === 'code' && status === 'started') return <p>Generating code</p>; | |
| if (isExecution) return <p>Executing code ({seconds}s)</p>; | |
| if (type === 'code' && status === 'completed') | |
| return <p>Code execution success ({seconds}s)</p>; | |
| if (type === 'code' && status === 'failed') | |
| return <p>Code execution failure ({seconds}s)</p>; | |
| return null; | |
| }; | |
| const ChunkPayloadAction: React.FC<{ | |
| payload: ChunkBody['payload']; | |
| }> = ({ payload }) => { | |
| if (!payload) return null; | |
| if (Array.isArray(payload)) { | |
| // [{title: 123, content, 345}, {title: ..., content: ...}] => ['title', 'content'] | |
| const keyArray = Array.from( | |
| payload.reduce((acc, curr) => { | |
| Object.keys(curr).forEach(key => acc.add(key)); | |
| return acc; | |
| }, new Set<string>()), | |
| ); | |
| return ( | |
| <Dialog> | |
| <DialogTrigger asChild> | |
| <Button variant="ghost" size="icon"> | |
| <IconListUnordered /> | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent | |
| className="max-w-5xl" | |
| onOpenAutoFocus={e => e.preventDefault()} | |
| > | |
| <Table> | |
| <TableHeader> | |
| <TableRow className="border-primary/50"> | |
| {keyArray.map(header => ( | |
| <TableHead key={header}>{header}</TableHead> | |
| ))} | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {payload.map((line, index) => ( | |
| <TableRow className="border-primary/50" key={index}> | |
| {keyArray.map(header => | |
| header === 'documentation' ? ( | |
| <TableCell key={header}> | |
| <Dialog> | |
| <DialogTrigger asChild> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="size-8 ml-[40%]" | |
| > | |
| <IconTerminalWindow className="text-teal-500 size-4" /> | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent className="max-w-5xl"> | |
| <CodeBlock language="md" value={line[header]} /> | |
| </DialogContent> | |
| </Dialog> | |
| </TableCell> | |
| ) : ( | |
| <TableCell key={header}>{line[header]}</TableCell> | |
| ), | |
| )} | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } else { | |
| return ( | |
| <Dialog> | |
| <DialogTrigger asChild> | |
| <Button variant="ghost" size="icon"> | |
| <IconCodeWrap /> | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent className="max-w-5xl"> | |
| <CodeResultDisplay codeResult={payload as CodeResult} /> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |
| }; | |
| export default ChatMessage; | |