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
@@ -25,11 +25,7 @@ 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 {
getCleanContent,
hasCitationsBlock,
useParsedCitations,
} from "@/core/citations";
import { shouldShowCitationLoading, useParsedCitations } from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks";
import {
extractReasoningContentFromMessage,
@@ -47,6 +43,8 @@ import { Tooltip } from "../tooltip";
import { useThread } from "./context";
import { SafeCitationContent } from "./safe-citation-content";
export function MessageGroup({
className,
messages,
@@ -124,12 +122,11 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
<MessageResponse
remarkPlugins={streamdownPlugins.remarkPlugins}
<SafeCitationContent
content={step.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
>
{getCleanContent(step.reasoning ?? "")}
</MessageResponse>
/>
}
></ChainOfThoughtStep>
) : (
@@ -177,12 +174,11 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<MessageResponse
remarkPlugins={streamdownPlugins.remarkPlugins}
<SafeCitationContent
content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
>
{getCleanContent(lastReasoningStep.reasoning ?? "")}
</MessageResponse>
/>
}
></ChainOfThoughtStep>
</ChainOfThoughtContent>
@@ -217,7 +213,7 @@ function ToolCall({
const threadIsLoading = thread.isLoading;
const fileContent = typeof args.content === "string" ? args.content : "";
const { citations } = useParsedCitations(fileContent);
const { citations, cleanContent } = useParsedCitations(fileContent);
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -363,12 +359,16 @@ function ToolCall({
}, 100);
}
// Check if this is a markdown file with citations
const isMarkdown =
path?.toLowerCase().endsWith(".md") ||
path?.toLowerCase().endsWith(".markdown");
const showCitationsLoading =
isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast;
isMarkdown &&
shouldShowCitationLoading(
fileContent,
cleanContent,
threadIsLoading && isLast,
);
return (
<>