ai-chatbot/components/message-actions.tsx
Your Name 53ddb7e585
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Initial commit - clean repository
2025-12-17 20:15:18 +08:00

219 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import equal from "fast-deep-equal";
import { memo } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { useCopyToClipboard } from "usehooks-ts";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { Action, Actions } from "./elements/actions";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
export function PureMessageActions({
chatId,
message,
vote,
isLoading,
setMode,
onImageEdit,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMode?: (mode: "view" | "edit") => void;
onImageEdit?: (imageUrl: string) => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) {
return null;
}
const textFromParts = message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
const handleCopy = async () => {
if (!textFromParts) {
toast.error("当前没有可复制的文本");
return;
}
await copyToClipboard(textFromParts);
toast.success("已复制到剪贴板");
};
// 检查消息中是否包含图片
const extractImageUrl = () => {
if (!textFromParts) return null;
// 匹配 Markdown 图片格式:![thumb|originalUrl](thumbnailUrl) 或 ![](url)
const imageRegex = /!\[(?:thumb\|([^\]]+))?\]\(([^)]+)\)/;
const match = textFromParts.match(imageRegex);
if (match) {
// 如果有原图 URL,使用原图;否则使用缩略图
return match[1] || match[2];
}
return null;
};
const imageUrl = extractImageUrl();
// User messages get edit (on hover) and copy actions
if (message.role === "user") {
return (
<Actions className="-mr-0.5 justify-end">
<div className="relative">
{setMode && (
<Action
className="-left-10 absolute top-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
data-testid="message-edit-button"
onClick={() => setMode("edit")}
tooltip="编辑内容"
>
<PencilEditIcon />
</Action>
)}
<Action onClick={handleCopy} tooltip="复制内容">
<CopyIcon />
</Action>
</div>
</Actions>
);
}
return (
<Actions className="-ml-0.5">
<Action onClick={handleCopy} tooltip="复制内容">
<CopyIcon />
</Action>
{imageUrl && onImageEdit && (
<Action
onClick={() => onImageEdit(imageUrl)}
tooltip="编辑图片"
>
<PencilEditIcon />
</Action>
)}
<Action
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
const upvote = fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: "up",
}),
});
toast.promise(upvote, {
loading: "正在点赞该回复...",
success: () => {
mutate<Vote[]>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: true,
},
];
},
{ revalidate: false }
);
return "已点赞该回复";
},
error: "点赞失败,请稍后重试",
});
}}
tooltip="点赞回复"
>
<ThumbUpIcon />
</Action>
<Action
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={() => {
const downvote = fetch("/api/vote", {
method: "PATCH",
body: JSON.stringify({
chatId,
messageId: message.id,
type: "down",
}),
});
toast.promise(downvote, {
loading: "正在点踩该回复...",
success: () => {
mutate<Vote[]>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) {
return [];
}
const votesWithoutCurrent = currentVotes.filter(
(currentVote) => currentVote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: false,
},
];
},
{ revalidate: false }
);
return "已点踩该回复";
},
error: "点踩失败,请稍后重试",
});
}}
tooltip="点踩回复"
>
<ThumbDownIcon />
</Action>
</Actions>
);
}
export const MessageActions = memo(
PureMessageActions,
(prevProps, nextProps) => {
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
return true;
}
);