Files
zjdataai-app/frontend/app/components/ui/chat/chat-message/chat-sources.tsx
T
2024-08-13 09:37:23 +08:00

136 lines
4.0 KiB
TypeScript

import { Check, Copy } from "lucide-react";
import { useMemo } from "react";
import { Button } from "../../button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../../hover-card";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
import { SourceData } from "../index";
import PdfDialog from "../widgets/PdfDialog";
import { useClientConfig } from "../hooks/use-config";
const SCORE_THRESHOLD = 0.3;
function truncateNumber(num: number | undefined, precision: number): number {
if (num == undefined || num == 0) return 0;
const factor = Math.pow(10, precision);
return Math.trunc(num * factor) / factor;
}
function SourceNumberButton({ index, score }: { index: number, score: number | undefined }) {
return (
<div className="text-xs w-45 h-45 rounded-full bg-gray-100 mb-2 flex items-center justify-center hover:text-white hover:bg-primary hover:cursor-pointer">
{truncateNumber(score, 2)}
</div>
);
}
type NodeInfo = {
id: string;
score?: number;
text: string;
url?: string;
};
export function ChatSources({ data }: { data: SourceData }) {
const { backend } = useClientConfig();
const sources: NodeInfo[] = useMemo(() => {
// aggregate nodes by url or file_path (get the highest one by score)
const nodesByPath: { [path: string]: NodeInfo } = {};
data.nodes
.filter((node) => (node.score ?? 1) > SCORE_THRESHOLD)
.sort((a, b) => (b.score ?? 1) - (a.score ?? 1))
.forEach((node) => {
const nodeInfo = {
id: node.id,
score: node.score,
text: node.text,
url: node.url,
};
const key = nodeInfo.url ?? nodeInfo.id; // use id as key for UNKNOWN type
if (!nodesByPath[key]) {
nodesByPath[key] = nodeInfo;
}
});
return Object.values(nodesByPath);
}, [data.nodes]);
if (sources.length === 0) return null;
return (
<div className="space-x-2 text-sm">
<span className="font-semibold">来源:</span>
<div className="inline-flex gap-1 items-center">
{sources.map((nodeInfo: NodeInfo, index: number) => {
if (nodeInfo.url?.endsWith(".pdf")) {
return (
<PdfDialog
key={nodeInfo.id}
documentId={nodeInfo.id}
url={backend+nodeInfo.url}
trigger={<SourceNumberButton index={index} score={nodeInfo.score} />}
/>
);
}
return (
<div key={nodeInfo.id}>
<HoverCard>
<HoverCardTrigger>
<SourceNumberButton index={index} score={nodeInfo.score}/>
</HoverCardTrigger>
<HoverCardContent className="w-[450px]">
<NodeInfo nodeInfo={nodeInfo} />
</HoverCardContent>
</HoverCard>
</div>
);
})}
</div>
</div>
);
}
function NodeInfo({ nodeInfo }: { nodeInfo: NodeInfo }) {
const { backend } = useClientConfig();
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 });
if (nodeInfo.url) {
// this is a node generated by the web loader or file loader,
// add a link to view its URL and a button to copy the URL to the clipboard
return (
<div className="flex items-center my-2">
<a
className="hover:text-blue-900 truncate"
href={backend+nodeInfo.url}
target="_blank"
>
<span>{nodeInfo.text}</span>
</a>
<Button
onClick={() => copyToClipboard(nodeInfo.url!)}
size="icon"
variant="ghost"
className="h-12 w-12 shrink-0"
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
);
}
// node generated by unknown loader, implement renderer by analyzing logged out metadata
return (
<p>
对不起, 未知文件类型. 无法打开当前的来源文件。
</p>
);
}