vision-agent / components /chat /ChatMessage.tsx
wuyiqunLu
fix: online stream message issue (#71)
46f65e5 unverified
raw
history blame
8.73 kB
import { useMemo, useState } from 'react';
import { CodeBlock } from '@/components/ui/CodeBlock';
import {
IconCheckCircle,
IconCodeWrap,
IconCrossCircle,
IconLandingAI,
IconListUnordered,
IconTerminalWindow,
IconUser,
IconOutput,
IconLog,
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 { Separator } from '../ui/Separator';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/Tooltip';
import { Dialog, DialogContent, DialogTrigger } from '../ui/Dialog';
import { Markdown } from './MemoizedReactMarkdown';
import Img from '../ui/Img';
export interface ChatMessageProps {
message: MessageUI;
isLoading: boolean;
}
export function ChatMessage({ message, isLoading }: ChatMessageProps) {
const { role, content, mediaUrl } = message;
return role === 'user' ? (
<UserChatMessage content={content} mediaUrl={mediaUrl} />
) : (
<AssistantChatMessage content={content} />
);
}
const UserChatMessage: React.FC<{
content: string;
mediaUrl?: string;
}> = ({ content, mediaUrl }) => {
return (
<div className="group relative mb-6 flex rounded-md bg-muted p-6 ml-auto mr-0 w-3/5">
<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-3 overflow-hidden">
<p>{content}</p>
{mediaUrl && (
<>
{mediaUrl?.endsWith('.mp4') ? (
<video src={mediaUrl} controls width={500} height={500} />
) : (
<Img
src={mediaUrl}
alt={mediaUrl}
quality={100}
sizes="(min-width: 66em) 15vw,
(min-width: 44em) 20vw,
100vw"
/>
)}
</>
)}
</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 ChunkTypeToTextDict: Record<ChunkBody['type'], string> = {
plans: 'Creating instructions',
tools: 'Retrieving tools',
code: 'Generating code',
final_code: 'Final result',
};
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>
);
}
};
const CodeResultDisplay: React.FC<{
codeResult: CodeResult;
}> = ({ codeResult }) => {
const { code, test, result } = codeResult;
const getDetail = () => {
if (!result) return {};
try {
const detail = JSON.parse(result);
return {
results: detail.results,
stderr: detail.logs.stderr,
stdout: detail.logs.stdout,
};
} catch {
return {};
}
};
const { results, stderr, stdout } = getDetail();
return (
<div className="rounded-lg overflow-hidden relative max-w-5xl">
<CodeBlock language="python" value={code} />
<div className="rounded-lg relative">
<div className="absolute left-1/2 -translate-x-1/2 -top-4 z-10">
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconTerminalWindow className="text-teal-500 size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl">
<CodeBlock language="python" value={test} />
</DialogContent>
</Dialog>
{Array.isArray(stderr) && (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<IconLog className="text-gray-500 size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-5xl">
<CodeBlock language="vim" value={stderr.join('').trim()} />
</DialogContent>
</Dialog>
)}
</div>
</div>
{Array.isArray(stdout) && !!stdout.join('').trim() && (
<>
<Separator />
<CodeBlock language="print" value={stdout.join('').trim()} />
</>
)}
{!!results.length && (
<>
<Separator />
<CodeBlock language="output" value={results} />
</>
)}
<Separator />
</div>
);
};
const AssistantChatMessage: React.FC<{
content: string;
}> = ({ content }) => {
const [formattedSections, codeResult] = useMemo(
() => formatStreamLogs(content),
[content],
);
return (
<div className="group relative mb-6 flex rounded-md bg-muted p-6 w-full">
<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">
{ChunkTypeToTextDict[section.type]}
</TableCell>
<TableCell className="text-right">
<ChunkPayloadAction payload={section.payload} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{codeResult && <CodeResultDisplay codeResult={codeResult} />}
</div>
</div>
);
};
export default UserChatMessage;