mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-18 12:04:45 +08:00
- 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 保持内部使用; 在文件头注释中说明展示规则。
177 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|