mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
refactor(frontend): consolidate citation logic, slim exports and impl
- SafeCitationContent: add loadingOnly and renderBody props.
- loadingOnly: show only loading indicator or null (e.g. write_file step).
- renderBody(parsed): custom body renderer (e.g. artifact preview).
- message-group write_file: use SafeCitationContent(content, isLoading,
rehypePlugins, loadingOnly) instead of local useParsedCitations +
shouldShowCitationLoading + CitationsLoadingIndicator. Pass rehypePlugins
into ToolCall.
- artifact-file-detail markdown preview: use SafeCitationContent with
renderBody((p) => <ArtifactFilePreview ... cleanContent={p.cleanContent}
citationMap={p.citationMap} />). Remove local shouldShowCitationLoading
and CitationsLoadingIndicator branch.
- core/citations: inline buildCitationMap into use-parsed-citations, remove
from utils; stop exporting hasCitationsBlock (internal to shouldShowCitationLoading).
- inline-citation: make InlineCitationCard, InlineCitationCardBody,
InlineCitationSource file-private (no longer exported).
Co-authored-by: Cursor <cursoragent@cursor.com>
---
refactor(前端): 收拢引用逻辑、精简导出与实现
- SafeCitationContent 新增 loadingOnly、renderBody。
- loadingOnly:仅显示加载或 null(如 write_file 步骤)。
- renderBody(parsed):自定义正文渲染(如 artifact 预览)。
- message-group write_file:改用 SafeCitationContent(loadingOnly),去掉
本地 useParsedCitations + shouldShowCitationLoading + CitationsLoadingIndicator,
并向 ToolCall 传入 rehypePlugins。
- artifact-file-detail 的 markdown 预览:改用 SafeCitationContent +
renderBody 渲染 ArtifactFilePreview,去掉本地加载判断与
CitationsLoadingIndicator 分支。
- core/citations:buildCitationMap 内联到 use-parsed-citations 并从 utils
删除;hasCitationsBlock 不再导出(仅 shouldShowCitationLoading 内部使用)。
- inline-citation:InlineCitationCard/Body/Source 改为文件内私有,不再导出。
This commit is contained in:
@@ -29,35 +29,31 @@ import {
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
}: ComponentProps<"div">) => (
|
||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
}: ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
}) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
|
||||
@@ -32,13 +32,11 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import type { Citation } from "@/core/citations";
|
||||
import {
|
||||
contentWithoutCitationsFromParsed,
|
||||
removeAllCitations,
|
||||
shouldShowCitationLoading,
|
||||
useParsedCitations,
|
||||
} from "@/core/citations";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -50,6 +48,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { SafeCitationContent } from "../messages/safe-citation-content";
|
||||
import { useThread } from "../messages/context";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
@@ -252,31 +251,27 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent className="p-0">
|
||||
{previewable && viewMode === "preview" && (
|
||||
{previewable &&
|
||||
viewMode === "preview" &&
|
||||
language === "markdown" &&
|
||||
content &&
|
||||
shouldShowCitationLoading(
|
||||
content,
|
||||
parsed.cleanContent,
|
||||
thread.isLoading,
|
||||
) ? (
|
||||
<div className="flex size-full items-center justify-center p-4">
|
||||
<CitationsLoadingIndicator
|
||||
citations={parsed.citations}
|
||||
className="my-0"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactFilePreview
|
||||
filepath={filepath}
|
||||
threadId={threadId}
|
||||
content && (
|
||||
<SafeCitationContent
|
||||
content={content}
|
||||
language={language ?? "text"}
|
||||
cleanContent={parsed.cleanContent}
|
||||
citationMap={parsed.citationMap}
|
||||
isLoading={thread.isLoading}
|
||||
rehypePlugins={streamdownPlugins.rehypePlugins}
|
||||
className="flex size-full items-center justify-center p-4 my-0"
|
||||
renderBody={(p) => (
|
||||
<ArtifactFilePreview
|
||||
filepath={filepath}
|
||||
threadId={threadId}
|
||||
content={content}
|
||||
language={language ?? "text"}
|
||||
cleanContent={p.cleanContent}
|
||||
citationMap={p.citationMap}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none"
|
||||
|
||||
@@ -22,10 +22,8 @@ import {
|
||||
ChainOfThoughtStep,
|
||||
} from "@/components/ai-elements/chain-of-thought";
|
||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { shouldShowCitationLoading, useParsedCitations } from "@/core/citations";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -130,7 +128,12 @@ export function MessageGroup({
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
) : (
|
||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
@@ -140,6 +143,7 @@ export function MessageGroup({
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
@@ -197,6 +201,7 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
rehypePlugins,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -205,6 +210,7 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||
@@ -213,7 +219,6 @@ function ToolCall({
|
||||
const threadIsLoading = thread.isLoading;
|
||||
|
||||
const fileContent = typeof args.content === "string" ? args.content : "";
|
||||
const { citations, cleanContent } = useParsedCitations(fileContent);
|
||||
|
||||
if (name === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -362,13 +367,6 @@ function ToolCall({
|
||||
const isMarkdown =
|
||||
path?.toLowerCase().endsWith(".md") ||
|
||||
path?.toLowerCase().endsWith(".markdown");
|
||||
const showCitationsLoading =
|
||||
isMarkdown &&
|
||||
shouldShowCitationLoading(
|
||||
fileContent,
|
||||
cleanContent,
|
||||
threadIsLoading && isLast,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -392,10 +390,14 @@ function ToolCall({
|
||||
</ChainOfThoughtSearchResult>
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
{showCitationsLoading && (
|
||||
<div className="mt-2 ml-8">
|
||||
<CitationsLoadingIndicator citations={citations} />
|
||||
</div>
|
||||
{isMarkdown && (
|
||||
<SafeCitationContent
|
||||
content={fileContent}
|
||||
isLoading={threadIsLoading && isLast}
|
||||
rehypePlugins={rehypePlugins}
|
||||
loadingOnly
|
||||
className="mt-2 ml-8"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ImgHTMLAttributes } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import {
|
||||
shouldShowCitationLoading,
|
||||
useParsedCitations,
|
||||
type UseParsedCitationsResult,
|
||||
} from "@/core/citations";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -25,7 +27,11 @@ export type SafeCitationContentProps = {
|
||||
className?: string;
|
||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||
isHuman?: boolean;
|
||||
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => React.ReactNode;
|
||||
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
|
||||
/** When true, only show loading indicator or null (e.g. write_file step). */
|
||||
loadingOnly?: boolean;
|
||||
/** When set, use instead of default MessageResponse (e.g. artifact preview). */
|
||||
renderBody?: (parsed: UseParsedCitationsResult) => ReactNode;
|
||||
};
|
||||
|
||||
/** Single place for citation-aware body: shows loading until citations complete (no half-finished refs), else body. */
|
||||
@@ -37,20 +43,12 @@ export function SafeCitationContent({
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
isHuman = false,
|
||||
img,
|
||||
loadingOnly = false,
|
||||
renderBody,
|
||||
}: SafeCitationContentProps) {
|
||||
const { citations, cleanContent, citationMap } = useParsedCitations(content);
|
||||
const parsed = useParsedCitations(content);
|
||||
const { citations, cleanContent, citationMap } = parsed;
|
||||
const showLoading = shouldShowCitationLoading(content, cleanContent, isLoading);
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<CitationsLoadingIndicator
|
||||
citations={citations}
|
||||
className={cn("my-2", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!cleanContent) return null;
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
createCitationMarkdownComponents({
|
||||
@@ -61,6 +59,19 @@ export function SafeCitationContent({
|
||||
}),
|
||||
[citationMap, isHuman, img],
|
||||
);
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
<CitationsLoadingIndicator
|
||||
citations={citations}
|
||||
className={cn("my-2", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (loadingOnly) return null;
|
||||
if (renderBody) return renderBody(parsed);
|
||||
if (!cleanContent) return null;
|
||||
|
||||
return (
|
||||
<MessageResponse
|
||||
className={className}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export {
|
||||
contentWithoutCitationsFromParsed,
|
||||
extractDomainFromUrl,
|
||||
hasCitationsBlock,
|
||||
isExternalUrl,
|
||||
parseCitations,
|
||||
removeAllCitations,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { buildCitationMap, parseCitations } from "./utils";
|
||||
import { parseCitations } from "./utils";
|
||||
import type { Citation } from "./utils";
|
||||
|
||||
export interface UseParsedCitationsResult {
|
||||
@@ -13,12 +13,12 @@ export interface UseParsedCitationsResult {
|
||||
|
||||
/**
|
||||
* Parse content for citations and build citation map. Memoized by content.
|
||||
* Use in message and artifact components to avoid repeating parseCitations + buildCitationMap.
|
||||
*/
|
||||
export function useParsedCitations(content: string): UseParsedCitationsResult {
|
||||
return useMemo(() => {
|
||||
const parsed = parseCitations(content ?? "");
|
||||
const citationMap = buildCitationMap(parsed.citations);
|
||||
const citationMap = new Map<string, Citation>();
|
||||
for (const c of parsed.citations) citationMap.set(c.url, c);
|
||||
return {
|
||||
citations: parsed.citations,
|
||||
cleanContent: parsed.cleanContent,
|
||||
|
||||
@@ -120,22 +120,6 @@ export function parseCitations(content: string): ParseCitationsResult {
|
||||
return { citations, cleanContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map from URL to Citation for quick lookup
|
||||
*
|
||||
* @param citations - Array of citations
|
||||
* @returns Map with URL as key and Citation as value
|
||||
*/
|
||||
export function buildCitationMap(
|
||||
citations: Citation[],
|
||||
): Map<string, Citation> {
|
||||
const map = new Map<string, Citation>();
|
||||
for (const citation of citations) {
|
||||
map.set(citation.url, citation);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the URL is external (http/https).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user