feat: add citation support in research report block and markdown
* feat: add citation support in research report block and markdown - Enhanced ResearchReportBlock to fetch citations based on researchId and pass them to the Markdown component. - Introduced CitationLink component to display citation metadata on hover for links in markdown. - Implemented CitationCard and CitationList components for displaying citation details and lists. - Updated Markdown component to handle citation links and inline citations. - Created HoverCard component for displaying citation information in a tooltip-like manner. - Modified store to manage citations, including setting and retrieving citations for ongoing research. - Added CitationsEvent type to handle citations in chat events and updated Message type to include citations. * fix(log): Enable the logging level when enabling the DEBUG environment variable (#793) * fix(frontend): render all tool calls in the frontend #796 (#797) * build(deps): bump jspdf from 3.0.4 to 4.0.0 in /web (#798) Bumps [jspdf](https://github.com/parallax/jsPDF) from 3.0.4 to 4.0.0. - [Release notes](https://github.com/parallax/jsPDF/releases) - [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md) - [Commits](https://github.com/parallax/jsPDF/compare/v3.0.4...v4.0.0) --- updated-dependencies: - dependency-name: jspdf dependency-version: 4.0.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(frontend):added the display of the 'analyst' message #800 (#801) * fix: migrate from deprecated create_react_agent to langchain.agents.create_agent (#802) * fix: migrate from deprecated create_react_agent to langchain.agents.create_agent Fixes #799 - Replace deprecated langgraph.prebuilt.create_react_agent with langchain.agents.create_agent (LangGraph 1.0 migration) - Add DynamicPromptMiddleware to handle dynamic prompt templates (replaces the 'prompt' callable parameter) - Add PreModelHookMiddleware to handle pre-model hooks (replaces the 'pre_model_hook' parameter) - Update AgentState import from langchain.agents in template.py - Update tests to use the new API * fix:update the code with review comments * fix: Add runtime parameter to compress_messages method(#803) * fix: Add runtime parameter to compress_messages method(#803) The compress_messages method was being called by PreModelHookMiddleware with both state and runtime parameters, but only accepted state parameter. This caused a TypeError when the middleware executed the pre_model_hook. Added optional runtime parameter to compress_messages signature to match the expected interface while maintaining backward compatibility. * Update the code with the review comments * fix: Refactor citation handling and add comprehensive tests for citation features * refactor: Clean up imports and formatting across citation modules * fix: Add monkeypatch to clear AGENT_RECURSION_LIMIT in recursion limit tests * feat: Enhance citation link handling in Markdown component * fix: Exclude citations from finish reason handling in mergeMessage function * fix(nodes): update message handling * fix(citations): improve citation extraction and handling in event processing * feat(citations): enhance citation extraction and handling with improved merging and normalization * fix(reporter): update citation formatting instructions for clarity and consistency * fix(reporter): prioritize using Markdown tables for data presentation and comparison --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LoftyComet <1277173875@qq。> Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { ExternalLink, Globe, Clock, Star } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "~/components/ui/hover-card";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type { Citation } from "~/core/messages";
|
||||
|
||||
// Re-export Citation type as CitationData for backward compatibility
|
||||
export type CitationData = Citation;
|
||||
|
||||
interface CitationLinkProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
citations: CitationData[];
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced link component that shows citation metadata on hover.
|
||||
* Used within markdown content to provide rich citation information.
|
||||
*/
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
citations,
|
||||
className,
|
||||
id,
|
||||
}: CitationLinkProps) {
|
||||
// Find matching citation data for this URL
|
||||
const { citation, index } = useMemo(() => {
|
||||
if (!href || !citations) return { citation: null, index: -1 };
|
||||
|
||||
// Try exact match first
|
||||
let matchIndex = citations.findIndex((c) => c.url === href);
|
||||
|
||||
// If not found, try versatile comparison using normalized URLs
|
||||
if (matchIndex === -1) {
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
return decodeURIComponent(url).trim();
|
||||
} catch {
|
||||
return url.trim();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedHref = normalizeUrl(href);
|
||||
|
||||
matchIndex = citations.findIndex(
|
||||
(c) => normalizeUrl(c.url) === normalizedHref
|
||||
);
|
||||
}
|
||||
|
||||
const match = matchIndex !== -1 ? citations[matchIndex] : null;
|
||||
|
||||
return { citation: match, index: matchIndex };
|
||||
}, [href, citations]);
|
||||
|
||||
// If no citation data found, render as regular link
|
||||
if (!citation) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("text-primary hover:underline", className)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCitationClick = (e: React.MouseEvent) => {
|
||||
// If it's an internal-looking citation (e.g. [1])
|
||||
// or if the user clicks the citation number in the text
|
||||
// we try to scroll to the reference list at the bottom
|
||||
if (index !== -1) {
|
||||
const targetId = `ref-${index + 1}`;
|
||||
const element = document.getElementById(targetId);
|
||||
if (element) {
|
||||
e.preventDefault();
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
// If element not found or index is -1, let the default behavior (open URL) happen
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
id={id}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleCitationClick}
|
||||
className={cn(
|
||||
"text-primary hover:underline inline-flex items-center gap-0.5 cursor-pointer scroll-mt-20",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className="text-xs text-muted-foreground ml-0.5">
|
||||
<ExternalLink className="h-3 w-3 inline" />
|
||||
</span>
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-80 p-4"
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<CitationCard citation={citation} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface CitationCardProps {
|
||||
citation: CitationData;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component displaying citation metadata.
|
||||
*/
|
||||
export function CitationCard({ citation, compact = false }: CitationCardProps) {
|
||||
const {
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
domain,
|
||||
relevance_score,
|
||||
accessed_at,
|
||||
source_type,
|
||||
} = citation;
|
||||
|
||||
// Format access date
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!accessed_at) return null;
|
||||
try {
|
||||
const date = new Date(accessed_at);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return accessed_at.slice(0, 10);
|
||||
}
|
||||
}, [accessed_at]);
|
||||
|
||||
// Format relevance score as percentage
|
||||
const relevancePercent = useMemo(() => {
|
||||
if (relevance_score == null || relevance_score <= 0) return null;
|
||||
return Math.round(relevance_score * 100);
|
||||
}, [relevance_score]);
|
||||
|
||||
return (
|
||||
<span className={cn("block space-y-2", compact && "space-y-1")}>
|
||||
{/* Title */}
|
||||
<span className="block font-semibold text-sm line-clamp-2 leading-snug">
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* Domain and metadata row */}
|
||||
<span className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{domain && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
{domain}
|
||||
</span>
|
||||
)}
|
||||
{formattedDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formattedDate}
|
||||
</span>
|
||||
)}
|
||||
{relevancePercent != null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3" />
|
||||
{relevancePercent}% match
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Description/snippet */}
|
||||
{description && !compact && (
|
||||
<span className="block text-xs text-muted-foreground line-clamp-3 leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Source type badge */}
|
||||
{source_type && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-secondary text-secondary-foreground">
|
||||
{source_type === "web_search" ? "Web" : source_type}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* URL preview */}
|
||||
<span className="block text-[10px] text-muted-foreground truncate opacity-60">
|
||||
{url}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface CitationListProps {
|
||||
citations: CitationData[];
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List component for displaying all citations.
|
||||
*/
|
||||
export function CitationList({
|
||||
citations,
|
||||
title = "Sources",
|
||||
className,
|
||||
}: CitationListProps) {
|
||||
if (!citations || citations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
<div className="space-y-2">
|
||||
{citations.map((citation, index) => (
|
||||
<div
|
||||
key={citation.url || index}
|
||||
className="p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-medium flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-foreground hover:text-primary hover:underline line-clamp-1"
|
||||
>
|
||||
{citation.title}
|
||||
</a>
|
||||
{citation.domain && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{citation.domain}
|
||||
</p>
|
||||
)}
|
||||
{citation.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{citation.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CitationBadgeProps {
|
||||
number: number;
|
||||
citation?: CitationData;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small numbered badge for inline citations.
|
||||
*/
|
||||
export function CitationBadge({ number, citation, onClick }: CitationBadgeProps) {
|
||||
const badge = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary/10 text-primary text-[10px] font-medium hover:bg-primary/20 transition-colors align-super ml-0.5 cursor-pointer"
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!citation) {
|
||||
return badge;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>{badge}</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72 p-3" side="top" sideOffset={4}>
|
||||
<CitationCard citation={citation} compact />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user