feat: 重构多轮RAG展示并完善报告协作、业务链审核与 agentfx 种子

抽出专用多轮RAG消息列表并升级会话模型;强化报告协作画布与检查器;业务链普通用户改为提交审核;扩展 agentfx 报告技能种子,并将含 admin 的用户名自动提权为管理员。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-09-03 17:34:16 +08:00
1 parent f109a95535
commit db333a4c33
30 files changed
+2878 -1482

No files matched your search

@@ -24,7 +24,6 @@ import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
@@ -123,9 +122,8 @@ const ChatBox: React.FC<{
// batches through DeerFlow's backend APIs.
const hasFrontendRag = useMemo(
() =>
(thread.values.messages ?? []).some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
(thread.values.messages ?? []).some((message) =>
isSingleRoundRagMessage(message),
),
[thread.values.messages],
);
@@ -973,8 +971,7 @@ function buildTurnReferenceBatches(
// GeneralQA's single-RAG stream sends `docs` before the answer. Make
// those cards available immediately, while keeping normal streaming turns
// on their existing "wait for an answer" behavior.
const isFrontendRagTurn =
isSingleRoundRagMessage(assistant) || isMultiRoundRagMessage(assistant);
const isFrontendRagTurn = isSingleRoundRagMessage(assistant);
if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) {
continue;
}
@@ -1025,7 +1022,7 @@ function findFrontendRagAssistant(
const message = messages[index];
if (
message?.type === "ai" &&
(isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message))
isSingleRoundRagMessage(message)
) {
return message;
}
@@ -7,8 +7,6 @@ import {
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
SparklesIcon,
} from 'lucide-react';
import {
memo,
@@ -77,7 +75,6 @@ import { humanMessagePlugins } from '@/core/streamdown';
import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
type MultiRoundRagRound,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
@@ -90,6 +87,7 @@ import { DeleteMessageButton } from './delete-message-button';
import { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list';
type EmbeddedPageContext = {
title?: string;
@@ -159,9 +157,9 @@ export function MessageListItem({
className,
fitRowToContent = false,
anchorId,
message,
questionMarkdown,
isLoading,
message,
questionMarkdown,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
@@ -178,9 +176,9 @@ export function MessageListItem({
* message. `scroll-mt` keeps the target clear of the overlay header.
*/
anchorId?: string;
message: Message;
/** Nearest preceding user question, used by the current-Q&A Word export. */
questionMarkdown?: string;
message: Message;
/** Nearest preceding user question, used by the current-Q&A Word export. */
questionMarkdown?: string;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
@@ -330,12 +328,12 @@ export function MessageListItem({
<div className="flex gap-1">
<CopyButton clipboardData={messageMarkdown} />
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
title="问答回答"
questionMarkdown={questionMarkdown}
/>
)}
{canSaveAssistantEdit && (
<Tooltip content="编辑消息">
@@ -380,12 +378,12 @@ function HumanMessageBlock({
messageId?: string;
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
filesList: ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
deleteButton?: React.ReactNode;
deleteButton?: ReactNode;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
@@ -629,9 +627,7 @@ function MessageContent_({
href={href}
{...props}
onReferenceClick={
isSingleRoundRag || isMultiRoundRag
? handleCitationClick
: undefined
isSingleRoundRag ? handleCitationClick : undefined
}
/>
);
@@ -658,7 +654,6 @@ function MessageContent_({
}),
[
handleCitationClick,
isMultiRoundRag,
isSingleRoundRag,
resolveArtifact,
threadId,
@@ -697,9 +692,12 @@ function MessageContent_({
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
if (isMultiRoundRag) {
return stripIncompleteCitationMarker(rawContent ?? '');
}
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]);
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins],
@@ -734,7 +732,7 @@ function MessageContent_({
}
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
@@ -755,7 +753,11 @@ function MessageContent_({
// it exists server-side (real, non-optimistic id). Deleting it cascades to
// its answer on the backend.
const canDelete =
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
!!onMessageDeleted &&
!!message.id &&
!message.id.startsWith('opt-') &&
!isMultiRoundRag &&
!isSingleRoundRag;
return (
<HumanMessageBlock
messageId={message.id}
@@ -764,8 +766,10 @@ function MessageContent_({
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
onEditSave={onEditSave}
onEditResubmit={
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
}
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
deleteButton={
canDelete ? (
<DeleteMessageButton
@@ -785,14 +789,12 @@ function MessageContent_({
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
{multiRoundTrace ? (
<MultiRoundRagContent
trace={multiRoundTrace}
{isMultiRoundRag ? (
<MultiRoundRagAssistantBody
content={contentToDisplay}
displaySources={displaySources}
thinkingTrace={multiRoundTrace}
isLoading={isLoading}
rehypePlugins={markdownRehypePlugins}
components={components}
/>
) : (
<AssistantDisplayContent
@@ -805,7 +807,7 @@ function MessageContent_({
/>
)}
</div>
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
{isSingleRoundRag && displaySources.length > 0 && (
<FrontendRagSourcesPanel
sources={displaySources}
onSelect={setSelectedCitation}
@@ -820,7 +822,7 @@ function MessageContent_({
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
{(isSingleRoundRag || isMultiRoundRag) && (
{isSingleRoundRag && (
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
@@ -833,11 +835,9 @@ function MessageContent_({
}
/**
* 单轮/多轮 RAG 专属的「引用来源」折叠面板。
* 单轮 RAG 专属的「引用来源」折叠面板。
*
* 参考 GeneralQA 的引用来源区:默认折叠,列出全部参考资料,点击任一条打开详情
* 弹窗(复用 SingleRoundRagCitationDialog)。这样即使模型正文里没有 `[1]` 编号,
* 用户也能看到本次回答依据的资料。
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
*/
function FrontendRagSourcesPanel({
sources,
@@ -941,349 +941,6 @@ export function SingleRoundRagCitationDialog({
);
}
function MultiRoundRagContent({
trace,
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
// Keep the active turn expanded so each new retrieval is visible as it
// arrives, then match GeneralQA by collapsing the completed trace. The
// header still carries the total round count for quick scanning.
setOpen(!trace.isComplete);
}, [trace.isComplete]);
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
onClick={() => setOpen((value) => !value)}
>
<div className="flex min-w-0 items-center gap-2">
{trace.isComplete ? (
<SearchIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索思考过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `共 ${trace.rounds.length} 轮推理与检索,可展开查看细节`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn(
'relative pl-5',
index < trace.rounds.length - 1 && 'pb-1',
)}
>
{index < trace.rounds.length - 1 && (
<span className="bg-border absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
</div>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
<div className="mb-3 text-sm font-semibold">最终回答</div>
{content ? (
<AssistantDisplayContent
content={content}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={rehypePlugins}
components={components}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成最终回答…
</div>
)}
</section>
</div>
);
}
function MultiRoundRagRoundCard({
index,
round,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!round.query || round.isSearching);
useEffect(() => {
// A newly created round stays expanded to expose its thought and loading
// query. Once that query has returned, retain only the round header until
// the user asks to inspect the completed detail again.
setOpen(!round.query || round.isSearching);
}, [round.id, round.isSearching, round.query]);
const thoughtContent = useMemo(
() =>
applyInlineCitations(round.thought, displaySources, {
appendSummary: false,
}),
[displaySources, round.thought],
);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="border-border/60 bg-background/80 overflow-hidden rounded-xl border shadow-sm">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2.5 text-left text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate">{round.title}</span>
{round.isSearching ? (
<span className="bg-primary/10 text-primary inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Loader2Icon className="size-3 motion-reduce:animate-none animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground shrink-0 text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3">
<div className="pt-3">
{round.thought && (
<MultiRoundRagDetailSection
title="思考内容"
icon={<SparklesIcon className="text-primary size-4" />}
>
<MarkdownContent
content={thoughtContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</MultiRoundRagDetailSection>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-lg border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{(round.query || round.isSearching) && (
<div
className={cn(
'border-primary/20 bg-primary/5 text-primary mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5 text-xs',
round.isSearching && 'animate-pulse motion-reduce:animate-none',
)}
aria-live={round.isSearching ? 'polite' : undefined}
>
{round.isSearching ? (
<Loader2Icon className="size-3.5 shrink-0 motion-reduce:animate-none animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="shrink-0 text-muted-foreground">检索词</span>
<span className="min-w-0 truncate font-medium">
{round.query || '正在生成检索词…'}
</span>
</div>
)}
{(round.query || round.isSearching || round.retrievals.length > 0) && (
<MultiRoundRagDetailSection
defaultOpen={!round.isSearching}
title="检索内容"
icon={<FileIcon className="text-primary size-3.5" />}
suffix={<span className="text-muted-foreground text-xs">{round.retrievals.length} 条</span>}
>
{round.isSearching ? (
<div className="text-muted-foreground flex items-center gap-2 py-1 text-sm">
<Loader2Icon className="text-primary size-4 motion-reduce:animate-none animate-spin" />
正在召回相关内容…
</div>
) : (
<div className="space-y-2">
{round.retrievals.length > 0 ? (
round.retrievals.map((item, resultIndex) => (
<MultiRoundRagResultCard
key={`${item.id ?? item.title}-${resultIndex}`}
item={item}
index={resultIndex + 1}
/>
))
) : (
<p className="text-muted-foreground py-1 text-sm">
本轮未召回相关资料。
</p>
)}
</div>
)}
</MultiRoundRagDetailSection>
)}
</div>
</CollapsibleContent>
</article>
</Collapsible>
);
}
function MultiRoundRagDetailSection({
title,
icon,
suffix,
defaultOpen = true,
children,
}: {
title: string;
icon: ReactNode;
suffix?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => setOpen(defaultOpen), [defaultOpen]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<section className="border-border/50 bg-muted/20 mt-3 overflow-hidden rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
{icon}
<span className="min-w-0 flex-1 text-sm font-medium">{title}</span>
{suffix}
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
{children}
</CollapsibleContent>
</section>
</Collapsible>
);
}
function MultiRoundRagResultCard({
item,
index,
}: {
item: MultiRoundRagRound['retrievals'][number];
index: number;
}) {
const [open, setOpen] = useState(index === 1);
const sourceMeta = [item.source, item.time].filter(Boolean).join(' · ');
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="border-border/60 hover:border-primary/30 bg-background rounded-lg border transition-colors">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 focus-visible:ring-ring flex min-h-11 w-full min-w-0 items-center gap-2 rounded-lg px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
>
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-semibold">
{index}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={item.title}>
{item.title}
</span>
<ChevronDownIcon
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/50 border-t px-3 pb-3 pt-2.5">
<p className="text-muted-foreground text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</p>
{(sourceMeta || item.url) && (
<div className="text-muted-foreground mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{sourceMeta && <span>{sourceMeta}</span>}
{item.url && (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
查看原文
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
): string | undefined {
@@ -321,11 +321,7 @@ export function MessageList({
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo(
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
() => messages.some((message) => isSingleRoundRagMessage(message)),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
@@ -340,7 +336,7 @@ export function MessageList({
// frontend tool-message scan below comes up empty (e.g. after a reload).
const referenceBatches = useContext(ReferenceBatchesContext);
// 单轮/多轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// 单轮 RAG 的参考资料存放在一条 hide_from_ui 的 tool 消息里(紧挨在
// assistant 消息之前)。allMessages 会把 hide_from_ui 消息过滤掉,导致通用
// 的 collectRuntimeDisplaySources 扫描不到它们。这里直接在原始消息流里按
// assistant 消息提取其对应的参考资料,保证 `[1]` 等编号能解析成可点击引用。
@@ -351,7 +347,7 @@ export function MessageList({
const msg = raw[i];
if (!msg || msg.type !== "ai" || !msg.id) continue;
const kwargs = msg.additional_kwargs;
if (!kwargs || (kwargs.single_round_rag !== true && kwargs.multi_round_rag !== true)) {
if (!kwargs || kwargs.single_round_rag !== true) {
continue;
}
let sources: RuntimeDisplaySource[] = [];
@@ -362,19 +358,13 @@ export function MessageList({
if (prev.type === "human" || prev.type === "ai") break;
if (prev.type !== "tool") continue;
const prevKwargs = prev.additional_kwargs;
if (
!prevKwargs ||
(prevKwargs.single_round_rag !== true && prevKwargs.multi_round_rag !== true)
) {
if (!prevKwargs || prevKwargs.single_round_rag !== true) {
continue;
}
const results = extractFrontendRagResults(prev.content);
if (results && results.length > 0) {
const toolName = String(
(prev as { name?: unknown }).name ??
(prevKwargs.multi_round_rag === true
? "multi_round_rag_references"
: "knowledge_base_search"),
(prev as { name?: unknown }).name ?? "knowledge_base_search",
);
sources = results.map((item, index) =>
toRuntimeDisplaySource(item, index + 1, toolName),
@@ -390,6 +380,7 @@ export function MessageList({
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
if (isMultiRoundRagMessage(msg)) return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
@@ -0,0 +1,472 @@
import {
BookOpenIcon,
ChevronDownIcon,
FileTextIcon,
Loader2Icon,
SearchIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
} from "@/components/ai-elements/message";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { MessageResponseProps } from "@/components/ai-elements/message";
import type {
MultiRoundRagChatMessage,
MultiRoundRagRetrieval,
MultiRoundRagRound,
MultiRoundRagTrace,
} from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { MarkdownContent } from "./markdown-content";
export function MultiRoundRagMessageList({
className,
messages,
isLoading,
paddingBottom = 160,
}: {
className?: string;
messages: MultiRoundRagChatMessage[];
isLoading: boolean;
paddingBottom?: number;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<Conversation className={cn("flex size-full flex-col justify-center", className)}>
<ConversationContent
className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-10"
>
{messages.map((message, index) => {
const isLastAssistant =
message.role === "assistant" && index === messages.length - 1;
return (
<AIElementMessage
key={message.id}
className="group/conversation-message relative w-full"
from={message.role === "user" ? "user" : "assistant"}
>
{message.role === "user" ? (
<AIElementMessageContent className="w-fit">
<p className="whitespace-pre-wrap text-[15px] leading-7">
{message.content}
</p>
</AIElementMessageContent>
) : (
<AIElementMessageContent className="w-full">
<MultiRoundRagAssistantBody
content={
typeof message.content === "string" ? message.content : ""
}
thinkingTrace={message.thinkingTrace}
isLoading={isLastAssistant && isLoading}
rehypePlugins={rehypePlugins}
/>
</AIElementMessageContent>
)}
</AIElementMessage>
);
})}
<div style={{ height: `${paddingBottom}px` }} />
</ConversationContent>
<ConversationScrollButton
className="pointer-events-auto z-20 size-9 shadow-md bottom-40"
aria-label="回到最新"
title="回到最新"
/>
</Conversation>
);
}
export function MultiRoundRagAssistantBody({
content,
thinkingTrace,
isLoading,
rehypePlugins,
}: {
content: string;
thinkingTrace?: MultiRoundRagTrace | null;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
}) {
const [selected, setSelected] = useState<MultiRoundRagRetrieval | null>(
null,
);
const trace = thinkingTrace;
const answer = trace?.finalAnswerMarkdown || content;
return (
<>
{trace && (
<MultiRoundRagTraceView
trace={trace}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onSelectRetrieval={setSelected}
/>
)}
<section className="mt-4">
<div className="text-foreground mb-2 text-sm font-medium">最终回答</div>
{answer ? (
<MarkdownContent
content={answer}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-0 text-[15px] leading-7"
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成思考过程与检索内容...
</div>
)}
</section>
<RetrievalDetailDialog
item={selected}
onOpenChange={(open) => {
if (!open) setSelected(null);
}}
/>
</>
);
}
function MultiRoundRagTraceView({
trace,
isLoading,
rehypePlugins,
onSelectRetrieval,
}: {
trace: MultiRoundRagTrace;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) {
const [open, setOpen] = useState(!trace.thinkingComplete);
useEffect(() => {
setOpen(!trace.thinkingComplete);
}, [trace.thinkingComplete]);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className="not-prose w-full min-w-0 max-w-5xl"
>
<CollapsibleTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors"
>
{trace.thinkingComplete ? (
<SparklesIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<span>{trace.title || "思考过程"}</span>
<span className="text-muted-foreground/70 text-xs">
共 {trace.rounds.length} 轮推理与检索
</span>
<ChevronDownIcon
className={cn("ml-auto size-4 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{trace.rounds.map((round, index) => (
<div
key={round.id}
className={cn("relative pl-5", index < trace.rounds.length - 1 && "pb-1")}
>
{index < trace.rounds.length - 1 && (
<span className="bg-primary/25 absolute bottom-0 left-[7px] top-8 w-px" />
)}
<span className="border-primary bg-background absolute left-0 top-4 size-3.5 rounded-full border-2" />
<MultiRoundRagRoundCard
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
onSelectRetrieval={onSelectRetrieval}
/>
</div>
))}
</CollapsibleContent>
</Collapsible>
);
}
function MultiRoundRagRoundCard({
index,
round,
isLoading,
rehypePlugins,
onSelectRetrieval,
}: {
index: number;
round: MultiRoundRagRound;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
}) {
const searching = Boolean(round.queryLoading);
const [open, setOpen] = useState(true);
useEffect(() => {
setOpen(searching || !round.query);
}, [round.id, round.query, searching]);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<article className="overflow-hidden rounded-lg border border-border/70 bg-background">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors"
>
<span className="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-medium">
{index}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{round.title}</span>
{searching ? (
<span className="text-primary inline-flex items-center gap-1 text-[11px]">
<Loader2Icon className="size-3 animate-spin" />
检索中
</span>
) : (
<span className="text-muted-foreground text-[11px]">已完成</span>
)}
<ChevronDownIcon
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 space-y-3 border-t px-3 pb-3 pt-3">
{round.thoughtMarkdown && (
<section>
<div className="text-muted-foreground mb-1.5 flex items-center gap-2 text-sm font-medium">
<SparklesIcon className="size-4" />
思考内容
</div>
<MarkdownContent
content={round.thoughtMarkdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="text-muted-foreground my-0 text-sm leading-6"
/>
</section>
)}
{round.queryShow && (round.query || searching) && (
<div
className="border-primary/20 bg-primary/5 text-primary inline-flex max-w-full items-center gap-2 rounded-full border px-3 py-1.5"
aria-live={searching ? "polite" : undefined}
>
{searching ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
) : (
<SearchIcon className="size-3.5 shrink-0" />
)}
<span className="text-primary/80 shrink-0 text-xs">
{round.queryTitle || "检索词"}
</span>
<span className="min-w-0 truncate text-xs font-medium">
{round.query || "正在生成检索词…"}
</span>
</div>
)}
{round.queryShow && (
<section>
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<FileTextIcon className="text-muted-foreground size-4" />
检索内容
<span className="text-muted-foreground text-xs font-normal">
{round.retrievals.length} 条
</span>
</div>
{searching ? (
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-border/70 px-3 py-3 text-sm">
<Loader2Icon className="text-primary size-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalCardList
items={round.retrievals}
onSelect={onSelectRetrieval}
/>
)}
</section>
)}
{(round.extractInfo || round.extractContent || round.extractLoading) && (
<section className="rounded-lg border border-border/70 px-3 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<BookOpenIcon className="text-muted-foreground size-4" />
阅读内容
{round.extractLoading && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.extractInfo && (
<p className="text-muted-foreground text-sm leading-6">{round.extractInfo}</p>
)}
{round.extractContent && (
<p className="mt-2 text-sm leading-6 whitespace-pre-wrap">{round.extractContent}</p>
)}
</section>
)}
{round.textidContent && (
<section className="rounded-lg border border-border/70 px-3 py-3 text-sm leading-6 whitespace-pre-wrap">
{round.textidContent}
</section>
)}
</CollapsibleContent>
</article>
</Collapsible>
);
}
function RetrievalCardList({
items,
onSelect,
}: {
items: MultiRoundRagRetrieval[];
onSelect: (item: MultiRoundRagRetrieval) => void;
}) {
if (items.length === 0) {
return <p className="text-muted-foreground py-1 text-sm">本轮未召回相关资料。</p>;
}
return (
<div className="space-y-2">
{items.map((item, index) => (
<RetrievalCard
key={`${item.id ?? item.title}-${index}`}
item={item}
index={index + 1}
defaultOpen={index === 0}
onSelect={onSelect}
/>
))}
</div>
);
}
function RetrievalCard({
item,
index,
defaultOpen,
onSelect,
}: {
item: MultiRoundRagRetrieval;
index: number;
defaultOpen: boolean;
onSelect: (item: MultiRoundRagRetrieval) => void;
}) {
const [open, setOpen] = useState(defaultOpen);
const meta = retrievalMeta(item);
if (item.type === "user_behavior") {
return (
<div className="rounded-xl border border-border/70 px-3 py-2 text-sm">
<span className="text-muted-foreground mr-2 font-medium">{index}.</span>
{item.title}
</div>
);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/40 flex min-h-11 w-full items-center gap-2 px-3 py-2.5 text-left transition-colors"
>
<span className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-md text-[11px] font-medium">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium" title={item.title}>
{item.title}
</div>
{meta && (
<div className="text-muted-foreground truncate text-xs">{meta}</div>
)}
</div>
<ChevronDownIcon
className={cn("text-muted-foreground size-4 shrink-0 transition-transform", !open && "-rotate-90")}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border/60 border-t px-3 pb-3 pt-2.5">
<button
type="button"
className="text-muted-foreground hover:text-foreground line-clamp-3 w-full text-left text-sm leading-6 whitespace-pre-wrap"
onClick={() => onSelect(item)}
>
{item.content || "暂无资料摘要"}
</button>
</CollapsibleContent>
</div>
</Collapsible>
);
}
function retrievalMeta(item: MultiRoundRagRetrieval): string {
if (item.type === "more") return `时间:${item.time || "-"}`;
if (item.type === "task") return `关键词:${item.keywords || "-"}`;
if (item.type === "hx") return `平台:${item.platform || "-"} 粉丝数:${item.fans ?? "-"}`;
if (
item.type === "taiwan_news" ||
item.type === "world_news" ||
item.type === "messaage_comment" ||
item.type === "america_news"
) {
return `平台:${item.platform || "-"}${item.comment_count ? ` 评论数:${item.comment_count}` : ""}`;
}
if (item.type === "get_time_data") return `时间:${item.time || "-"} 热度:${item.hot_num ?? "-"}`;
return [item.source, item.time].filter(Boolean).join(" · ");
}
function RetrievalDetailDialog({
item,
onOpenChange,
}: {
item: MultiRoundRagRetrieval | null;
onOpenChange: (open: boolean) => void;
}) {
const content = item?.content;
return (
<Dialog open={!!item} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">{item?.title || "引用资料"}</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
{item?.time && <span className="block">时间:{item.time}</span>}
{item?.source && <span className="block">来源:{item.source}</span>}
</DialogDescription>
</DialogHeader>
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-xl p-3 text-sm leading-7 whitespace-pre-wrap">
{content || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}