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:
LoftyComet
2026-01-24 17:49:13 +08:00
committed by GitHub
parent 612bddd3fb
commit b7f0f54aa0
22 changed files with 2125 additions and 29 deletions

View File

@@ -7,11 +7,12 @@ import { LoadingAnimation } from "~/components/deer-flow/loading-animation";
import { Markdown } from "~/components/deer-flow/markdown";
import ReportEditor from "~/components/editor";
import { useReplay } from "~/core/replay";
import { useMessage, useStore } from "~/core/store";
import { useCitations, useMessage, useStore } from "~/core/store";
import { cn } from "~/lib/utils";
export function ResearchReportBlock({
className,
researchId,
messageId,
editing,
}: {
@@ -21,6 +22,7 @@ export function ResearchReportBlock({
editing: boolean;
}) {
const message = useMessage(messageId);
const citations = useCitations(researchId);
const { isReplay } = useReplay();
const handleMarkdownChange = useCallback(
(markdown: string) => {
@@ -61,7 +63,7 @@ export function ResearchReportBlock({
/>
) : (
<>
<Markdown animated checkLinkCredibility>
<Markdown animated checkLinkCredibility citations={citations}>
{message?.content}
</Markdown>
{message?.isStreaming && <LoadingAnimation className="my-12" />}

View File

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

View File

@@ -20,6 +20,7 @@ import { cn } from "~/lib/utils";
import Image from "./image";
import { Tooltip } from "./tooltip";
import { Link } from "./link";
import { CitationLink, type CitationData } from "./citation";
export function Markdown({
className,
@@ -28,6 +29,7 @@ export function Markdown({
enableCopy,
animated = false,
checkLinkCredibility = false,
citations = [],
...props
}: ReactMarkdownOptions & {
className?: string;
@@ -35,21 +37,127 @@ export function Markdown({
style?: React.CSSProperties;
animated?: boolean;
checkLinkCredibility?: boolean;
citations?: CitationData[];
}) {
// Pre-compute normalized URL map for O(1) lookup
const citationMap = useMemo(() => {
const map = new Map<string, number>();
citations?.forEach((c, index) => {
if (!c.url) return;
// Add exact match
map.set(c.url, index);
// Add decoded match
try {
const decoded = decodeURIComponent(c.url);
if (decoded !== c.url) map.set(decoded, index);
} catch {}
// Add encoded match
try {
const encoded = encodeURI(c.url);
if (encoded !== c.url) map.set(encoded, index);
} catch {}
});
return map;
}, [citations]);
const components: ReactMarkdownOptions["components"] = useMemo(() => {
return {
a: ({ href, children }) => (
<Link href={href} checkLinkCredibility={checkLinkCredibility}>
{children}
</Link>
),
a: ({ href, children }) => {
const hrefStr = href ?? "";
// Handle citation anchor targets (rendered in Reference list)
// Format: [[n]](#citation-target-n)
const targetMatch = hrefStr.match(/^#citation-target-(\d+)$/);
if (targetMatch) {
const index = targetMatch[1];
return (
<span
id={`ref-${index}`}
className="font-bold text-primary scroll-mt-20"
>
[{index}]
</span>
);
}
// Handle inline citation links (rendered in text)
// Format: [[n]](#ref-n), [n](#ref1), [n](#1)
const linkMatch = hrefStr.match(/^#(?:ref-?)?(\d+)$/);
if (linkMatch) {
return (
<a
href={hrefStr}
className="text-primary hover:underline cursor-pointer marker-link"
onClick={(e) => {
e.preventDefault();
const targetId = `ref-${linkMatch[1]}`;
const element = document.getElementById(targetId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}}
>
{children}
</a>
);
}
// If we have citation data, use CitationLink for enhanced display
if (citations && citations.length > 0) {
// Find if this URL is one of our citations
const citationIndex = citationMap.get(hrefStr) ?? -1;
if (citationIndex !== -1) {
// Heuristic to determine if this is a citation target (in Reference list)
// vs a citation link (in text).
// Targets are usually the full title, while links are numbers like [1].
const childrenText = Array.isArray(children)
? children.join("")
: String(children);
// Heuristic: inline citation text usually looks like a numeric marker
// rather than a full title. We treat the following as "inline":
// "1", "[1]", "^1^", "[^1]" (with optional surrounding whitespace).
// This pattern matches either:
// - a bracketed number: "[1]"
// - a caret-style number: "1", "^1", "1^", "^1^"
// and ignores surrounding whitespace.
const inlineCitationPattern = /^\s*(?:\[\d+\]|\^?\d+\^?)\s*$/;
const isInline = inlineCitationPattern.test(childrenText);
return (
<CitationLink
href={hrefStr}
citations={citations}
id={!isInline ? `ref-${citationIndex + 1}` : undefined}
>
{children}
</CitationLink>
);
}
return (
<CitationLink href={hrefStr} citations={citations}>
{children}
</CitationLink>
);
}
// Otherwise fall back to regular Link
return (
<Link href={href} checkLinkCredibility={checkLinkCredibility}>
{children}
</Link>
);
},
img: ({ src, alt }) => (
<a href={src as string} target="_blank" rel="noopener noreferrer">
<Image className="rounded" src={src as string} alt={alt ?? ""} />
</a>
),
};
}, [checkLinkCredibility]);
}, [checkLinkCredibility, citations, citationMap]);
const rehypePlugins = useMemo<NonNullable<ReactMarkdownOptions["rehypePlugins"]>>(() => {
const plugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [[

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "~/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { Option } from "../messages";
import type { Citation, Option } from "../messages";
// Tool Calls
@@ -76,9 +76,18 @@ export interface InterruptEvent
}
> {}
export interface CitationsEvent {
type: "citations";
data: {
thread_id: string;
citations: Citation[];
};
}
export type ChatEvent =
| MessageChunkEvent
| ToolCallsEvent
| ToolCallChunksEvent
| ToolCallResultEvent
| InterruptEvent;
| InterruptEvent
| CitationsEvent;

View File

@@ -53,7 +53,7 @@ export function mergeMessage(message: Message, event: ChatEvent) {
} else if (event.type === "interrupt") {
mergeInterruptMessage(message, event);
}
if (event.data.finish_reason) {
if (event.type !== "citations" && event.data.finish_reason) {
message.finishReason = event.data.finish_reason;
message.isStreaming = false;
if (message.toolCalls) {

View File

@@ -25,6 +25,7 @@ export interface Message {
finishReason?: "stop" | "interrupt" | "tool_calls";
interruptFeedback?: string;
resources?: Array<Resource>;
citations?: Array<Citation>;
}
export interface Option {
@@ -45,3 +46,14 @@ export interface Resource {
title: string;
description?: string;
}
export interface Citation {
url: string;
title: string;
description?: string;
content_snippet?: string;
domain?: string;
relevance_score?: number;
accessed_at?: string;
source_type?: string;
}

View File

@@ -7,7 +7,7 @@ import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
import { chatStream, generatePodcast } from "../api";
import type { Message, Resource } from "../messages";
import type { Citation, Message, Resource } from "../messages";
import { mergeMessage } from "../messages";
import { parseJSON } from "../utils";
@@ -25,6 +25,7 @@ export const useStore = create<{
researchReportIds: Map<string, string>;
researchActivityIds: Map<string, string[]>;
researchQueries: Map<string, string>;
researchCitations: Map<string, Citation[]>;
ongoingResearchId: string | null;
openResearchId: string | null;
@@ -34,6 +35,7 @@ export const useStore = create<{
openResearch: (researchId: string | null) => void;
closeResearch: () => void;
setOngoingResearch: (researchId: string | null) => void;
setCitations: (researchId: string, citations: Citation[]) => void;
}>((set) => ({
responding: false,
threadId: THREAD_ID,
@@ -44,6 +46,7 @@ export const useStore = create<{
researchReportIds: new Map<string, string>(),
researchActivityIds: new Map<string, string[]>(),
researchQueries: new Map<string, string>(),
researchCitations: new Map<string, Citation[]>(),
ongoingResearchId: null,
openResearchId: null,
@@ -80,6 +83,11 @@ export const useStore = create<{
setOngoingResearch(researchId: string | null) {
set({ ongoingResearchId: researchId });
},
setCitations(researchId: string, citations: Citation[]) {
set((state) => ({
researchCitations: new Map(state.researchCitations).set(researchId, citations),
}));
},
}));
export async function sendMessage(
@@ -148,6 +156,15 @@ export async function sendMessage(
const { type, data } = event;
let message: Message | undefined;
// Handle citations event: store citations for the current research
if (type === "citations") {
const ongoingResearchId = useStore.getState().ongoingResearchId;
if (ongoingResearchId && data.citations) {
useStore.getState().setCitations(ongoingResearchId, data.citations);
}
continue;
}
// Handle tool_call_result specially: use the message that contains the tool call
if (type === "tool_call_result") {
message = findMessageByToolCallId(data.tool_call_id);
@@ -496,3 +513,15 @@ export function useToolCalls() {
}),
);
}
export function useCitations(researchId: string | null | undefined) {
return useStore(
useShallow((state) =>
researchId ? state.researchCitations.get(researchId) ?? [] : []
),
);
}
export function getCitations(researchId: string): Citation[] {
return useStore.getState().researchCitations.get(researchId) ?? [];
}