mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-24 06:34:46 +08:00
feat(frontend): unify citation logic and prevent half-finished citations
- Add SafeCitationContent as single component for citation-aware body: useParsedCitations + shouldShowCitationLoading; show loading until citations complete, then render body with createCitationMarkdownComponents. Supports optional remarkPlugins, rehypePlugins, isHuman, img. - Refactor MessageListItem: assistant message body now uses SafeCitationContent only; remove duplicate useParsedCitations, shouldShowCitationLoading, createCitationMarkdownComponents and CitationsLoadingIndicator logic. Human messages keep plain AIElementMessageResponse (no citation parsing). - Use SafeCitationContent for clarification, present-files (message-list), thinking steps and write_file loading (message-group), subtask result (subtask-card). Artifact markdown preview keeps same guard (shouldShowCitationLoading) with ArtifactFilePreview. - Unify loading condition: shouldShowCitationLoading(rawContent, cleanContent, isLoading) is the single source of truth. Show loading when (isLoading && hasCitationsBlock(rawContent)) or when (hasCitationsBlock(rawContent) && hasUnreplacedCitationRefs(cleanContent)) so Pro/Ultra modes also show "loading citations" and half-finished [cite-N] never appear. - message-group write_file: replace hasCitationsBlock + threadIsLoading with shouldShowCitationLoading(fileContent, cleanContent, threadIsLoading && isLast) for consistency. - citations/utils: parse incomplete <citations> during streaming; remove isCitationsBlockIncomplete; keep hasUnreplacedCitationRefs internal; document display rule in file header. Co-authored-by: Cursor <cursoragent@cursor.com> --- feat(前端): 统一引用逻辑并杜绝半成品引用 - 新增 SafeCitationContent 作为引用正文的唯一出口:内部使用 useParsedCitations + shouldShowCitationLoading,在引用未就绪时只显示 「正在整理引用」,就绪后用 createCitationMarkdownComponents 渲染正文; 支持可选 remarkPlugins、rehypePlugins、isHuman、img。 - 重构 MessageListItem:助手消息正文仅通过 SafeCitationContent 渲染, 删除重复的 useParsedCitations、shouldShowCitationLoading、 createCitationMarkdownComponents、CitationsLoadingIndicator 等逻辑; 用户消息仍用 AIElementMessageResponse,不做引用解析。 - 澄清、present-files(message-list)、思考步骤与 write_file 加载 (message-group)、子任务结果(subtask-card)均使用 SafeCitationContent;Artifact 的 markdown 预览仍用同一 guard shouldShowCitationLoading,正文由 ArtifactFilePreview 渲染。 - 统一加载条件:shouldShowCitationLoading(rawContent, cleanContent, isLoading) 为唯一判断。在「流式中且已有引用块」或「有引用块且 cleanContent 中仍有未替换的 [cite-N]」时仅显示加载,从而在 Pro/Ultra 下也能看到「正在整理引用」,且永不出现半成品 [cite-N]。 - message-group 的 write_file:用 shouldShowCitationLoading( fileContent, cleanContent, threadIsLoading && isLast) 替代 hasCitationsBlock + threadIsLoading,与其他场景一致。 - citations/utils:流式时解析未闭合的 <citations>;移除 isCitationsBlockIncomplete;hasUnreplacedCitationRefs 保持内部使用; 在文件头注释中说明展示规则。
This commit is contained in:
@@ -4,10 +4,6 @@ import { useParams } from "next/navigation";
|
||||
import { memo, useMemo } from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
|
||||
import {
|
||||
CitationAwareLink,
|
||||
CitationsLoadingIndicator,
|
||||
} from "@/components/ai-elements/inline-citation";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
@@ -16,11 +12,7 @@ import {
|
||||
} from "@/components/ai-elements/message";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import {
|
||||
isCitationsBlockIncomplete,
|
||||
removeAllCitations,
|
||||
useParsedCitations,
|
||||
} from "@/core/citations";
|
||||
import { removeAllCitations } from "@/core/citations";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -28,10 +20,11 @@ import {
|
||||
type UploadedFile,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { SafeCitationContent } from "./safe-citation-content";
|
||||
|
||||
export function MessageListItem({
|
||||
className,
|
||||
@@ -116,79 +109,36 @@ function MessageContent_({
|
||||
const isHuman = message.type === "human";
|
||||
const { thread_id } = useParams<{ thread_id: string }>();
|
||||
|
||||
// Content to parse for citations (and optionally uploaded files)
|
||||
const { contentToParse, uploadedFiles, isLoadingCitations } = useMemo(() => {
|
||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||
const { contentToParse, uploadedFiles } = useMemo(() => {
|
||||
if (!isLoading && reasoningContent && !rawContent) {
|
||||
return {
|
||||
contentToParse: reasoningContent,
|
||||
uploadedFiles: [] as UploadedFile[],
|
||||
isLoadingCitations: false,
|
||||
};
|
||||
return { contentToParse: reasoningContent, uploadedFiles: [] as UploadedFile[] };
|
||||
}
|
||||
|
||||
if (isHuman && rawContent) {
|
||||
const { files, cleanContent: contentWithoutFiles } =
|
||||
parseUploadedFiles(rawContent);
|
||||
return {
|
||||
contentToParse: contentWithoutFiles,
|
||||
uploadedFiles: files,
|
||||
isLoadingCitations: false,
|
||||
};
|
||||
return { contentToParse: contentWithoutFiles, uploadedFiles: files };
|
||||
}
|
||||
|
||||
return {
|
||||
contentToParse: rawContent ?? "",
|
||||
uploadedFiles: [] as UploadedFile[],
|
||||
isLoadingCitations:
|
||||
isLoading && isCitationsBlockIncomplete(rawContent ?? ""),
|
||||
};
|
||||
}, [isLoading, message, isHuman]);
|
||||
}, [isLoading, rawContent, reasoningContent, isHuman]);
|
||||
|
||||
const { citations, cleanContent, citationMap } =
|
||||
useParsedCitations(contentToParse);
|
||||
|
||||
// Shared markdown components
|
||||
const markdownComponents = useMemo(() => ({
|
||||
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<CitationAwareLink
|
||||
{...props}
|
||||
citationMap={citationMap}
|
||||
isHuman={isHuman}
|
||||
isLoadingCitations={isLoadingCitations}
|
||||
/>
|
||||
),
|
||||
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
|
||||
),
|
||||
}), [citationMap, thread_id, isHuman, isLoadingCitations]);
|
||||
|
||||
// Render message response
|
||||
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text
|
||||
const messageResponse = cleanContent ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={isHuman ? humanMessagePlugins.remarkPlugins : streamdownPlugins.remarkPlugins}
|
||||
rehypePlugins={isHuman ? humanMessagePlugins.rehypePlugins : [...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{cleanContent}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
|
||||
// Uploaded files list
|
||||
const filesList = uploadedFiles.length > 0 && thread_id ? (
|
||||
<UploadedFilesList files={uploadedFiles} threadId={thread_id} />
|
||||
) : null;
|
||||
|
||||
// Citations loading indicator
|
||||
const citationsLoadingIndicator = isLoadingCitations ? (
|
||||
<CitationsLoadingIndicator citations={citations} className="my-3" />
|
||||
) : null;
|
||||
|
||||
// Human messages with uploaded files: render outside bubble
|
||||
if (isHuman && uploadedFiles.length > 0) {
|
||||
if (isHuman) {
|
||||
const messageResponse = contentToParse ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
>
|
||||
{contentToParse}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
<div className={cn("ml-auto flex flex-col gap-2", className)}>
|
||||
{filesList}
|
||||
@@ -201,12 +151,23 @@ function MessageContent_({
|
||||
);
|
||||
}
|
||||
|
||||
// Default rendering
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
{messageResponse}
|
||||
{citationsLoadingIndicator}
|
||||
<SafeCitationContent
|
||||
content={contentToParse}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
className="my-3"
|
||||
isHuman={false}
|
||||
img={(props) => (
|
||||
<MessageImage
|
||||
{...props}
|
||||
threadId={thread_id}
|
||||
maxWidth="90%"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user