mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-12 01:54:45 +08:00
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:
@@ -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" />}
|
||||
|
||||
308
web/src/components/deer-flow/citation.tsx
Normal file
308
web/src/components/deer-flow/citation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"]> = [[
|
||||
|
||||
34
web/src/components/ui/hover-card.tsx
Normal file
34
web/src/components/ui/hover-card.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) ?? [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user