Files
ZY-Agent/web/src/components/deer-flow/citation.tsx
T
LoftyComet b7f0f54aa0 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>
2026-01-24 17:49:13 +08:00

309 lines
8.4 KiB
TypeScript

// 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>
);
}