219 lines
5.7 KiB
TypeScript
219 lines
5.7 KiB
TypeScript
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 图片格式: 或 
|
||
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;
|
||
}
|
||
);
|