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:
LofiSu
2026-02-09 15:01:51 +08:00
parent 804d988002
commit 4f9d1d524e
9 changed files with 309 additions and 161 deletions

View File

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