frigate/web/src/components/chat/ChatMessage.tsx

209 lines
5.9 KiB
TypeScript
Raw Normal View History

2026-02-18 04:48:46 +03:00
import { useState, useEffect, useRef } from "react";
2026-02-17 02:57:42 +03:00
import ReactMarkdown from "react-markdown";
2026-02-19 04:22:00 +03:00
import remarkGfm from "remark-gfm";
2026-02-17 05:20:14 +03:00
import { useTranslation } from "react-i18next";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
2026-02-18 04:48:46 +03:00
import { FaCopy, FaPencilAlt } from "react-icons/fa";
import { FaArrowUpLong } from "react-icons/fa6";
2026-02-17 05:20:14 +03:00
import { Button } from "@/components/ui/button";
2026-02-18 04:48:46 +03:00
import { Textarea } from "@/components/ui/textarea";
2026-02-17 05:20:14 +03:00
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
2026-02-17 02:57:42 +03:00
import { cn } from "@/lib/utils";
type MessageBubbleProps = {
role: "user" | "assistant";
content: string;
2026-02-18 04:48:46 +03:00
messageIndex?: number;
onEditSubmit?: (messageIndex: number, newContent: string) => void;
2026-02-18 06:18:50 +03:00
isComplete?: boolean;
2026-02-17 02:57:42 +03:00
};
2026-02-18 04:48:46 +03:00
export function MessageBubble({
role,
content,
messageIndex = 0,
onEditSubmit,
2026-02-18 06:18:50 +03:00
isComplete = true,
2026-02-18 04:48:46 +03:00
}: MessageBubbleProps) {
2026-02-17 05:20:14 +03:00
const { t } = useTranslation(["views/chat", "common"]);
2026-02-17 02:57:42 +03:00
const isUser = role === "user";
2026-02-18 04:48:46 +03:00
const [isEditing, setIsEditing] = useState(false);
const [draftContent, setDraftContent] = useState(content);
const editInputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraftContent(content);
}, [content]);
useEffect(() => {
if (isEditing) {
editInputRef.current?.focus();
editInputRef.current?.setSelectionRange(
editInputRef.current.value.length,
editInputRef.current.value.length,
);
}
}, [isEditing]);
2026-02-17 02:57:42 +03:00
2026-02-17 05:20:14 +03:00
const handleCopy = () => {
const text = content?.trim() || "";
if (!text) return;
if (copy(text)) {
toast.success(t("button.copiedToClipboard", { ns: "common" }));
}
};
2026-02-18 04:48:46 +03:00
const handleEditClick = () => {
setDraftContent(content);
setIsEditing(true);
};
const handleEditSubmit = () => {
const trimmed = draftContent.trim();
if (!trimmed || onEditSubmit == null) return;
onEditSubmit(messageIndex, trimmed);
setIsEditing(false);
};
const handleEditCancel = () => {
setDraftContent(content);
setIsEditing(false);
};
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleEditSubmit();
}
if (e.key === "Escape") {
handleEditCancel();
}
};
if (isUser && isEditing) {
return (
<div className="flex w-full max-w-full flex-col gap-2 self-end">
<Textarea
ref={editInputRef}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
onKeyDown={handleEditKeyDown}
className="min-h-[80px] w-full resize-y rounded-lg bg-primary px-3 py-2 text-primary-foreground placeholder:text-primary-foreground/60"
placeholder={t("placeholder")}
rows={3}
/>
<div className="flex items-center gap-2 self-end">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={handleEditCancel}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
size="icon"
className="size-9 rounded-full"
disabled={!draftContent.trim()}
onClick={handleEditSubmit}
aria-label={t("send")}
>
<FaArrowUpLong size="16" />
</Button>
</div>
</div>
);
}
2026-02-17 02:57:42 +03:00
return (
<div
className={cn(
2026-02-17 05:20:14 +03:00
"flex flex-col gap-1",
isUser ? "items-end self-end" : "items-start self-start",
2026-02-17 02:57:42 +03:00
)}
>
2026-02-17 05:20:14 +03:00
<div
className={cn(
"rounded-lg px-3 py-2",
isUser ? "bg-primary text-primary-foreground" : "bg-muted",
)}
>
2026-02-19 04:22:00 +03:00
{isUser ? (
content
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
table: ({ node: _n, ...props }) => (
<table
className="my-2 w-full border-collapse border border-border"
{...props}
/>
),
th: ({ node: _n, ...props }) => (
<th
className="border border-border bg-muted/50 px-2 py-1 text-left text-sm font-medium"
{...props}
/>
),
td: ({ node: _n, ...props }) => (
<td
className="border border-border px-2 py-1 text-sm"
{...props}
/>
),
}}
>
{content}
</ReactMarkdown>
)}
2026-02-17 05:20:14 +03:00
</div>
2026-02-18 04:48:46 +03:00
<div className="flex items-center gap-0.5">
{isUser && onEditSubmit != null && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={handleEditClick}
aria-label={t("button.edit", { ns: "common" })}
>
<FaPencilAlt className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("button.edit", { ns: "common" })}
</TooltipContent>
</Tooltip>
)}
2026-02-18 06:18:50 +03:00
{isComplete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
onClick={handleCopy}
disabled={!content?.trim()}
aria-label={t("button.copy", { ns: "common" })}
>
<FaCopy className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("button.copy", { ns: "common" })}
</TooltipContent>
</Tooltip>
)}
2026-02-18 04:48:46 +03:00
</div>
2026-02-17 02:57:42 +03:00
</div>
);
}