Files
deer-flow/frontend/src/components/workspace/messages/subtask-card.tsx
LofiSu 4f9d1d524e 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 保持内部使用;
  在文件头注释中说明展示规则。
2026-02-09 15:01:51 +08:00

177 lines
5.8 KiB
TypeScript

import {
CheckCircleIcon,
ChevronUp,
ClipboardListIcon,
Loader2Icon,
XCircleIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Streamdown } from "streamdown";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { Shimmer } from "@/components/ai-elements/shimmer";
import { Button } from "@/components/ui/button";
import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
streamdownPlugins,
streamdownPluginsWithWordAnimation,
} from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils";
import { FlipDisplay } from "../flip-display";
import { SafeCitationContent } from "./safe-citation-content";
export function SubtaskCard({
className,
taskId,
isLoading,
}: {
className?: string;
taskId: string;
isLoading: boolean;
}) {
const { t } = useI18n();
const [collapsed, setCollapsed] = useState(true);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const task = useSubtask(taskId)!;
const icon = useMemo(() => {
if (task.status === "completed") {
return <CheckCircleIcon className="size-3" />;
} else if (task.status === "failed") {
return <XCircleIcon className="size-3 text-red-500" />;
} else if (task.status === "in_progress") {
return <Loader2Icon className="size-3 animate-spin" />;
}
}, [task.status]);
return (
<ChainOfThought
className={cn("relative w-full gap-2 rounded-lg border py-0", className)}
open={!collapsed}
>
<div
className={cn(
"ambilight z-[-1]",
task.status === "in_progress" ? "enabled" : "",
)}
></div>
{task.status === "in_progress" && (
<>
<ShineBorder
borderWidth={1.5}
shineColor={["#A07CFE", "#FE8FB5", "#FFBE7B"]}
/>
</>
)}
<div className="bg-background/95 flex w-full flex-col rounded-lg">
<div className="flex w-full items-center justify-between p-0.5">
<Button
className="w-full items-start justify-start text-left"
variant="ghost"
onClick={() => setCollapsed(!collapsed)}
>
<div className="flex w-full items-center justify-between">
<ChainOfThoughtStep
className="font-normal"
label={
task.status === "in_progress" ? (
<Shimmer duration={3} spread={3}>
{task.description}
</Shimmer>
) : (
task.description
)
}
icon={<ClipboardListIcon />}
></ChainOfThoughtStep>
<div className="flex items-center gap-1">
{collapsed && (
<div
className={cn(
"text-muted-foreground flex items-center gap-1 text-xs font-normal",
task.status === "failed" ? "text-red-500 opacity-67" : "",
)}
>
{icon}
<FlipDisplay
className="max-w-[420px] truncate pb-1"
uniqueKey={task.latestMessage?.id ?? ""}
>
{task.status === "in_progress" &&
task.latestMessage &&
hasToolCalls(task.latestMessage)
? explainLastToolCall(task.latestMessage, t)
: t.subtasks[task.status]}
</FlipDisplay>
</div>
)}
<ChevronUp
className={cn(
"text-muted-foreground size-4",
!collapsed ? "" : "rotate-180",
)}
/>
</div>
</div>
</Button>
</div>
<ChainOfThoughtContent className="px-4 pb-4">
{task.prompt && (
<ChainOfThoughtStep
label={
<Streamdown {...streamdownPluginsWithWordAnimation}>
{task.prompt}
</Streamdown>
}
></ChainOfThoughtStep>
)}
{task.status === "in_progress" &&
task.latestMessage &&
hasToolCalls(task.latestMessage) && (
<ChainOfThoughtStep
label={t.subtasks.in_progress}
icon={<Loader2Icon className="size-4 animate-spin" />}
>
{explainLastToolCall(task.latestMessage, t)}
</ChainOfThoughtStep>
)}
{task.status === "completed" && (
<>
<ChainOfThoughtStep
label={t.subtasks.completed}
icon={<CheckCircleIcon className="size-4" />}
></ChainOfThoughtStep>
<ChainOfThoughtStep
label={
task.result ? (
<SafeCitationContent
content={task.result}
isLoading={false}
rehypePlugins={rehypePlugins}
/>
) : null
}
></ChainOfThoughtStep>
</>
)}
{task.status === "failed" && (
<ChainOfThoughtStep
label={<div className="text-red-500">{task.error}</div>}
icon={<XCircleIcon className="size-4 text-red-500" />}
></ChainOfThoughtStep>
)}
</ChainOfThoughtContent>
</div>
</ChainOfThought>
);
}