功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
import {
|
|
BookOpenIcon,
|
|
ChevronDownIcon,
|
|
FileTextIcon,
|
|
Loader2Icon,
|
|
SearchIcon,
|
|
SparklesIcon,
|
|
} from "lucide-react";
|
|
import { 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);
|
|
|
|
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}
|
|
isActiveRound={isLoading && index === trace.rounds.length - 1}
|
|
rehypePlugins={rehypePlugins}
|
|
onSelectRetrieval={onSelectRetrieval}
|
|
/>
|
|
</div>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
function MultiRoundRagRoundCard({
|
|
index,
|
|
round,
|
|
isLoading,
|
|
isActiveRound,
|
|
rehypePlugins,
|
|
onSelectRetrieval,
|
|
}: {
|
|
index: number;
|
|
round: MultiRoundRagRound;
|
|
isLoading: boolean;
|
|
isActiveRound: boolean;
|
|
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
|
onSelectRetrieval: (item: MultiRoundRagRetrieval) => void;
|
|
}) {
|
|
const query = round.query.trim();
|
|
const searching = Boolean(query && round.queryLoading);
|
|
const showQuery = Boolean(query);
|
|
const showRetrievals = showQuery || round.retrievals.length > 0;
|
|
const [open, setOpen] = useState(true);
|
|
|
|
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>
|
|
) : isActiveRound ? null : (
|
|
<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>
|
|
)}
|
|
{showQuery && (
|
|
<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">
|
|
{query}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{showRetrievals && (
|
|
<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>
|
|
);
|
|
}
|