mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-15 19:04:45 +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 { Shimmer } from "./shimmer";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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} />
|
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
const InlineCitationCardBody = ({
|
||||||
|
|
||||||
export const InlineCitationCardBody = ({
|
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: InlineCitationCardBodyProps) => (
|
}: ComponentProps<"div">) => (
|
||||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
const InlineCitationSource = ({
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InlineCitationSource = ({
|
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
description,
|
description,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: InlineCitationSourceProps) => (
|
}: ComponentProps<"div"> & {
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
description?: string;
|
||||||
|
}) => (
|
||||||
<div className={cn("space-y-1", className)} {...props}>
|
<div className={cn("space-y-1", className)} {...props}>
|
||||||
{title && (
|
{title && (
|
||||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
<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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||||
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import type { Citation } from "@/core/citations";
|
import type { Citation } from "@/core/citations";
|
||||||
import {
|
import {
|
||||||
contentWithoutCitationsFromParsed,
|
contentWithoutCitationsFromParsed,
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
shouldShowCitationLoading,
|
|
||||||
useParsedCitations,
|
useParsedCitations,
|
||||||
} from "@/core/citations";
|
} from "@/core/citations";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
@@ -50,6 +48,7 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
|
import { SafeCitationContent } from "../messages/safe-citation-content";
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
@@ -252,31 +251,27 @@ export function ArtifactFileDetail({
|
|||||||
</div>
|
</div>
|
||||||
</ArtifactHeader>
|
</ArtifactHeader>
|
||||||
<ArtifactContent className="p-0">
|
<ArtifactContent className="p-0">
|
||||||
{previewable && viewMode === "preview" && (
|
{previewable &&
|
||||||
|
viewMode === "preview" &&
|
||||||
language === "markdown" &&
|
language === "markdown" &&
|
||||||
content &&
|
content && (
|
||||||
shouldShowCitationLoading(
|
<SafeCitationContent
|
||||||
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={content}
|
content={content}
|
||||||
language={language ?? "text"}
|
isLoading={thread.isLoading}
|
||||||
cleanContent={parsed.cleanContent}
|
rehypePlugins={streamdownPlugins.rehypePlugins}
|
||||||
citationMap={parsed.citationMap}
|
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" && (
|
{isCodeFile && viewMode === "code" && (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
className="size-full resize-none rounded-none border-none"
|
className="size-full resize-none rounded-none border-none"
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ import {
|
|||||||
ChainOfThoughtStep,
|
ChainOfThoughtStep,
|
||||||
} from "@/components/ai-elements/chain-of-thought";
|
} from "@/components/ai-elements/chain-of-thought";
|
||||||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||||
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
|
||||||
import { MessageResponse } from "@/components/ai-elements/message";
|
import { MessageResponse } from "@/components/ai-elements/message";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { shouldShowCitationLoading, useParsedCitations } from "@/core/citations";
|
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
@@ -130,7 +128,12 @@ export function MessageGroup({
|
|||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
) : (
|
) : (
|
||||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
<ToolCall
|
||||||
|
key={step.id}
|
||||||
|
{...step}
|
||||||
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{lastToolCallStep && (
|
{lastToolCallStep && (
|
||||||
@@ -140,6 +143,7 @@ export function MessageGroup({
|
|||||||
{...lastToolCallStep}
|
{...lastToolCallStep}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
/>
|
/>
|
||||||
</FlipDisplay>
|
</FlipDisplay>
|
||||||
)}
|
)}
|
||||||
@@ -197,6 +201,7 @@ function ToolCall({
|
|||||||
result,
|
result,
|
||||||
isLast = false,
|
isLast = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
rehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
id?: string;
|
id?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
@@ -205,6 +210,7 @@ function ToolCall({
|
|||||||
result?: string | Record<string, unknown>;
|
result?: string | Record<string, unknown>;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||||
@@ -213,7 +219,6 @@ function ToolCall({
|
|||||||
const threadIsLoading = thread.isLoading;
|
const threadIsLoading = thread.isLoading;
|
||||||
|
|
||||||
const fileContent = typeof args.content === "string" ? args.content : "";
|
const fileContent = typeof args.content === "string" ? args.content : "";
|
||||||
const { citations, cleanContent } = useParsedCitations(fileContent);
|
|
||||||
|
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||||
@@ -362,13 +367,6 @@ function ToolCall({
|
|||||||
const isMarkdown =
|
const isMarkdown =
|
||||||
path?.toLowerCase().endsWith(".md") ||
|
path?.toLowerCase().endsWith(".md") ||
|
||||||
path?.toLowerCase().endsWith(".markdown");
|
path?.toLowerCase().endsWith(".markdown");
|
||||||
const showCitationsLoading =
|
|
||||||
isMarkdown &&
|
|
||||||
shouldShowCitationLoading(
|
|
||||||
fileContent,
|
|
||||||
cleanContent,
|
|
||||||
threadIsLoading && isLast,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -392,10 +390,14 @@ function ToolCall({
|
|||||||
</ChainOfThoughtSearchResult>
|
</ChainOfThoughtSearchResult>
|
||||||
)}
|
)}
|
||||||
</ChainOfThoughtStep>
|
</ChainOfThoughtStep>
|
||||||
{showCitationsLoading && (
|
{isMarkdown && (
|
||||||
<div className="mt-2 ml-8">
|
<SafeCitationContent
|
||||||
<CitationsLoadingIndicator citations={citations} />
|
content={fileContent}
|
||||||
</div>
|
isLoading={threadIsLoading && isLast}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
loadingOnly
|
||||||
|
className="mt-2 ml-8"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ImgHTMLAttributes } from "react";
|
import type { ImgHTMLAttributes } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
shouldShowCitationLoading,
|
shouldShowCitationLoading,
|
||||||
useParsedCitations,
|
useParsedCitations,
|
||||||
|
type UseParsedCitationsResult,
|
||||||
} from "@/core/citations";
|
} from "@/core/citations";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -25,7 +27,11 @@ export type SafeCitationContentProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||||
isHuman?: boolean;
|
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. */
|
/** 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,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
isHuman = false,
|
isHuman = false,
|
||||||
img,
|
img,
|
||||||
|
loadingOnly = false,
|
||||||
|
renderBody,
|
||||||
}: SafeCitationContentProps) {
|
}: SafeCitationContentProps) {
|
||||||
const { citations, cleanContent, citationMap } = useParsedCitations(content);
|
const parsed = useParsedCitations(content);
|
||||||
|
const { citations, cleanContent, citationMap } = parsed;
|
||||||
const showLoading = shouldShowCitationLoading(content, cleanContent, isLoading);
|
const showLoading = shouldShowCitationLoading(content, cleanContent, isLoading);
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
return (
|
|
||||||
<CitationsLoadingIndicator
|
|
||||||
citations={citations}
|
|
||||||
className={cn("my-2", className)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!cleanContent) return null;
|
|
||||||
|
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createCitationMarkdownComponents({
|
createCitationMarkdownComponents({
|
||||||
@@ -61,6 +59,19 @@ export function SafeCitationContent({
|
|||||||
}),
|
}),
|
||||||
[citationMap, isHuman, img],
|
[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 (
|
return (
|
||||||
<MessageResponse
|
<MessageResponse
|
||||||
className={className}
|
className={className}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
contentWithoutCitationsFromParsed,
|
contentWithoutCitationsFromParsed,
|
||||||
extractDomainFromUrl,
|
extractDomainFromUrl,
|
||||||
hasCitationsBlock,
|
|
||||||
isExternalUrl,
|
isExternalUrl,
|
||||||
parseCitations,
|
parseCitations,
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { buildCitationMap, parseCitations } from "./utils";
|
import { parseCitations } from "./utils";
|
||||||
import type { Citation } from "./utils";
|
import type { Citation } from "./utils";
|
||||||
|
|
||||||
export interface UseParsedCitationsResult {
|
export interface UseParsedCitationsResult {
|
||||||
@@ -13,12 +13,12 @@ export interface UseParsedCitationsResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse content for citations and build citation map. Memoized by content.
|
* 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 {
|
export function useParsedCitations(content: string): UseParsedCitationsResult {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const parsed = parseCitations(content ?? "");
|
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 {
|
return {
|
||||||
citations: parsed.citations,
|
citations: parsed.citations,
|
||||||
cleanContent: parsed.cleanContent,
|
cleanContent: parsed.cleanContent,
|
||||||
|
|||||||
@@ -120,22 +120,6 @@ export function parseCitations(content: string): ParseCitationsResult {
|
|||||||
return { citations, cleanContent };
|
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).
|
* Whether the URL is external (http/https).
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user