picoclaw/web/frontend/src/components/chat/assistant-message.tsx
LC 8e3e517135
feat: render mixed Markdown+HTML in assistant messages and skills (#1900)
* feat(chat): render mixed Markdown+HTML in assistant messages using rehype-raw + rehype-sanitize (safe default)

* build: remove irrelevant changes of pnpm-lock.yaml

* feat(skills): enable rendering of Markdown with HTML in skill details using rehype-raw and rehype-sanitize

* fix(agent): use ModelName in loop tests
2026-03-23 17:25:27 +08:00

69 lines
2.2 KiB
TypeScript

import { IconCheck, IconCopy } from "@tabler/icons-react"
import { useState } from "react"
import ReactMarkdown from "react-markdown"
import rehypeRaw from "rehype-raw"
import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
interface AssistantMessageProps {
content: string
timestamp?: string | number
}
export function AssistantMessage({
content,
timestamp = "",
}: AssistantMessageProps) {
const [isCopied, setIsCopied] = useState(false)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
const handleCopy = () => {
navigator.clipboard.writeText(content).then(() => {
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
})
}
return (
<div className="group flex w-full flex-col gap-1.5">
<div className="text-muted-foreground flex items-center justify-between gap-2 px-1 text-xs opacity-70">
<div className="flex items-center gap-2">
<span>PicoClaw</span>
{formattedTimestamp && (
<>
<span className="opacity-50"></span>
<span>{formattedTimestamp}</span>
</>
)}
</div>
</div>
<div className="bg-card text-card-foreground relative overflow-hidden rounded-xl border">
<div className="prose dark:prose-invert prose-p:my-2 prose-pre:my-2 prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-950 prose-pre:p-3 max-w-none p-4 text-[15px] leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{content}
</ReactMarkdown>
</div>
<Button
variant="ghost"
size="icon"
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<IconCheck className="h-4 w-4 text-green-500" />
) : (
<IconCopy className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
</div>
)
}