"use client";
import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react";
import { AssistantMarkdown } from "@/components/chat/assistant-markdown";
import { Button } from "@/components/ui/button";
import { authFetch } from "@/lib/auth/client";
import type { Attachment } from "@/lib/chat/types";
import type { ChatMessage as ChatMessageType } from "@/lib/chat/types";
type ChatMessageProps = {
canRetry: boolean;
message: ChatMessageType;
onRetry: () => void;
};
export function ChatMessage({ canRetry, message, onRetry }: ChatMessageProps) {
const [copied, setCopied] = useState(false);
const [downloadError, setDownloadError] = useState(false);
async function downloadAttachment(attachment: Attachment) {
setDownloadError(false);
try {
const response = await authFetch(`/api/attachments/${attachment.id}`, {
cache: "no-store",
});
if (!response.ok) throw new Error("Attachment download failed");
const objectUrl = URL.createObjectURL(await response.blob());
try {
const link = document.createElement("a");
link.href = objectUrl;
link.download = attachment.name;
link.click();
} finally {
URL.revokeObjectURL(objectUrl);
}
} catch {
setDownloadError(true);
}
}
if (message.role === "user") {
return (
<article className="flex justify-end py-3">
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-muted px-4 py-2.5 text-sm leading-6 md:max-w-[75%]">
<p className="whitespace-pre-wrap">{message.content}</p>
{message.attachments?.map((attachment) => (
<button
aria-label={`Скачать ${attachment.name}`}
className="mt-2 block text-left text-xs underline"
key={attachment.id}
onClick={() => void downloadAttachment(attachment)}
type="button"
>
{attachment.name}
</button>
))}
{downloadError ? (
<p className="mt-2 text-xs text-destructive" role="alert">
Не удалось скачать вложение
</p>
) : null}
</div>
</article>
);
}
return (
<article
aria-live="polite"
className="group py-4"
data-status={message.status}
>
<div className="flex gap-3">
<div className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-primary text-[10px] font-semibold text-primary-foreground">
AI
</div>
<div className="min-w-0 flex-1">
{message.status === "streaming" && !message.content ? (
<span
aria-label="Модель формирует ответ"
className="inline-flex items-center gap-1 py-2"
role="status"
>
{[0, 1, 2].map((index) => (
<span
aria-hidden="true"
className="size-1.5 animate-bounce rounded-full bg-muted-foreground motion-reduce:animate-none"
data-testid="response-placeholder-dot"
key={index}
style={{ animationDelay: `${index * 120}ms` }}
/>
))}
</span>
) : message.content ? (
<AssistantMarkdown
content={message.content}
isStreaming={message.status === "streaming"}
/>
) : null}
{message.status === "stopped" ? (
<p className="mt-2 text-xs text-muted-foreground">Генерация остановлена</p>
) : null}
{message.content || canRetry ? (
<div className="mt-2 flex items-center gap-1">
{message.content ? (
<Button
aria-label="Копировать ответ"
onClick={async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
}}
size="icon-sm"
title="Копировать ответ"
type="button"
variant="ghost"
>
{copied ? (
<Check aria-hidden="true" />
) : (
<Copy aria-hidden="true" />
)}
</Button>
) : null}
{canRetry ? (
<Button
aria-label="Повторить запрос"
onClick={onRetry}
size="icon-sm"
title="Повторить запрос"
type="button"
variant="ghost"
>
<RefreshCw aria-hidden="true" />
</Button>
) : null}
</div>
) : null}
</div>
</div>
</article>
);
}