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>
);
}
@@ -7,7 +7,7 @@
* ``GET {backend}/taskAnalyseSearch/cop-task-detail?taskId=``(带 DeerFlow 登录态,任务数据
* 来自 TaskCOP 任务表,即任务列表 / 报告导入用的同一份)。
* 2. **固定使用内置智能体** ``agentfx-analyst``(后端 seeder ``_agentfx_seed.py`` 启动时补建:
* 搜集信息 → 生成 report.md + task-reports.json(14 类)→ 总结 → 前端「入库」协助卡 /
* 搜集信息 → hits 落盘 → task-report-* 技能函数转换 14 类 task-reports.json → 写 report.md → 总结 → 前端「入库」协助卡 /
* task-report-import 技能入库),不走「配置管理」业务映射。
* 3. 路由挂在 ``plan-task-agentfx`` 前缀下,页面代码见 [[AgentFxWorkspace]]。
*
File diff suppressed because it is too large. Load diff
+9 -9
View File
@@ -70,7 +70,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -332,19 +332,19 @@ export default function ChatPage() {
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
const ragMessages = toLangGraphMessages(multiRoundRag.messages);
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
ragMessages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return base;
}
const messages = [
...base.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages],
ragMessages,
);
return {
...base,
values: { ...base.values, messages },
@@ -355,9 +355,9 @@ export default function ChatPage() {
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
researchDisplayThread,
singleRoundRag.isLoading,
singleRoundRag.messages,
researchDisplayThread,
]);
const writingArtifactPath = useMemo(
@@ -876,7 +876,7 @@ export default function ChatPage() {
status={
thread.error
? "error"
: displayThread.isLoading
: displayThread.isLoading || multiRoundRag.isLoading
? "streaming"
: "ready"
}
@@ -487,6 +487,7 @@ function runResearchAgent(store: MockStore, script: ResearchScript, baseDelay: n
setNodeStatus(store, script.nodeId, "completed");
agentRun.status = "completed";
agentRun.ended_at = now();
emit(store, "session.snapshot", { agent_runs: [{ ...agentRun }] });
const artifactId = store.artifactIds.at(-1);
if (artifactId) emit(store, "artifact.validated", { artifact_id: artifactId });
});
@@ -543,6 +544,7 @@ function executePlan(store: MockStore, plan: ReportPlanCandidate): void {
later(store, 300, () => {
setNodeStatus(store, "n-req", "completed");
coordRun.status = "completed";
emit(store, "session.snapshot", { agent_runs: [{ ...coordRun }] });
});
let cursor = 4200;
@@ -1,58 +1,53 @@
"use client";
import { memo } from "react";
import {
AlertTriangle,
Check,
Circle,
Loader2,
MessagesSquare,
X,
} from "lucide-react";
import { Check, Loader2, X } from "lucide-react";
import type { AgentRun } from "@/report-collaboration/api/types";
import { cn } from "@/lib/utils";
import { roleIcon } from "@/report-collaboration/components/ReportFlowNode";
interface AgentStageSelectorProps {
/** 并行阶段标签(协调者首位);空数组时不渲染。 */
/** 当前并行波次成员;空数组时不渲染。 */
tabs: AgentRun[];
phaseLabel?: string | null;
focusedAgentRunId: string | null;
onFocus: (agentRunId: string | null) => void;
onFocus: (agentRunId: string) => void;
/** 正在流式输出的成员。 */
streamingAgentRunIds: Set<string>;
/** 请求用户协助(待回复)的成员。 */
awaitingAgentRunIds: Set<string>;
}
function statusIcon(agentRun: AgentRun, streaming: boolean, awaiting: boolean) {
if (awaiting) {
return <AlertTriangle className="size-3.5 shrink-0" style={{ color: "var(--rc-waiting)" }} aria-label="待回复" />;
}
if (agentRun.status === "completed") {
return <Check className="size-3.5 shrink-0" style={{ color: "var(--rc-success)" }} aria-label="已完成" />;
}
if (agentRun.status === "failed" || agentRun.status === "cancelled") {
return <X className="size-3.5 shrink-0" style={{ color: "var(--rc-danger)" }} aria-label={agentRun.status === "failed" ? "失败" : "已取消"} />;
}
if (agentRun.status === "running" || streaming) {
return <Loader2 className="rc-spin size-3.5 shrink-0" style={{ color: "var(--rc-running)" }} aria-label="运行中" />;
}
return <Circle className="size-3 shrink-0 opacity-50" aria-label="等待" />;
function avatarStatus(
agentRun: AgentRun,
streaming: boolean,
awaiting: boolean,
): "running" | "awaiting" | "done" | "error" | "queued" {
if (awaiting) return "awaiting";
if (agentRun.status === "failed" || agentRun.status === "cancelled") return "error";
if (agentRun.status === "completed") return "done";
if (agentRun.status === "running" || streaming) return "running";
return "queued";
}
function statusText(agentRun: AgentRun, streaming: boolean, awaiting: boolean): string {
if (awaiting) return "待回复";
if (agentRun.status === "completed") return "已完成";
if (agentRun.status === "failed") return "失败";
if (agentRun.status === "cancelled") return "已取消";
if (agentRun.status === "running" || streaming) return "进行中";
return "排队中";
function statusLabel(status: ReturnType<typeof avatarStatus>, displayName: string): string {
const suffix =
status === "running"
? "进行中"
: status === "awaiting"
? "待回复"
: status === "done"
? "已完成"
: status === "error"
? "失败"
: "排队中";
return `${displayName}(${suffix})`;
}
/**
* 并行阶段成员选择器:只切换同一消息流的聚焦范围,不创建第二套执行视图。
* 用户手动切换后保持选择;未聚焦成员的事件继续接收并持久更新。
* 并行波次头像条(对齐会商第二步 sandbox):
* 只展示当前同时执行的多个智能体;点头像切换消息列表到该成员的执行过程。
* 未选中成员的事件继续接收并持久更新。
*/
export const AgentStageSelector = memo(function AgentStageSelector({
tabs,
@@ -63,41 +58,56 @@ export const AgentStageSelector = memo(function AgentStageSelector({
awaitingAgentRunIds,
}: AgentStageSelectorProps) {
if (tabs.length === 0) return null;
const awaitingCount = tabs.filter((tab) => awaitingAgentRunIds.has(tab.agent_run_id)).length;
const doneCount = tabs.filter((tab) => tab.status === "completed").length;
const focused = focusedAgentRunId ?? tabs[0]?.agent_run_id;
const runningCount = tabs.filter((tab) => {
const status = avatarStatus(
tab,
streamingAgentRunIds.has(tab.agent_run_id),
awaitingAgentRunIds.has(tab.agent_run_id),
);
return status === "running" || status === "awaiting";
}).length;
return (
<div className="rc-agent-tabs" role="tablist" aria-label="并行成员">
<div className="flex shrink-0 items-center gap-1 pr-1 text-[11px] text-[var(--rc-text-secondary)]">
<MessagesSquare className="size-3.5" aria-hidden />
<span className="max-w-28 truncate">{phaseLabel ?? "并行协作"}</span>
<span>
{doneCount}/{tabs.length}
</span>
{awaitingCount > 0 && <span className="rc-badge-num">{awaitingCount}</span>}
<div className="rc-agent-strip-meta">
<span>{phaseLabel ?? `并行 ${tabs.length} 席`}</span>
{runningCount > 0 && <span className="rc-agent-strip-running">{runningCount} 进行中</span>}
</div>
<div className="rc-agent-avatars">
{tabs.map((tab) => {
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
const status = avatarStatus(tab, streaming, awaiting);
const active = focused === tab.agent_run_id;
return (
<button
key={tab.agent_run_id}
type="button"
role="tab"
aria-selected={active}
data-active={active}
data-status={status}
data-role={tab.role}
className="rc-agent-avatar"
title={statusLabel(status, tab.display_name)}
aria-label={statusLabel(status, tab.display_name)}
onClick={() => onFocus(tab.agent_run_id)}
>
{status === "running" && <span className="rc-agent-avatar-ping" aria-hidden />}
<span className="rc-agent-avatar-face" aria-hidden>
{roleIcon(tab.role)}
</span>
<span className="rc-agent-avatar-badge" data-status={status} aria-hidden>
{status === "running" && <Loader2 className="rc-spin size-2.5" />}
{status === "awaiting" && <span className="rc-agent-avatar-dot" />}
{status === "done" && <Check className="size-2.5" strokeWidth={3} />}
{status === "error" && <X className="size-2.5" strokeWidth={3} />}
</span>
</button>
);
})}
</div>
{tabs.map((tab) => {
const streaming = streamingAgentRunIds.has(tab.agent_run_id);
const awaiting = awaitingAgentRunIds.has(tab.agent_run_id);
const active = (focusedAgentRunId ?? tabs[0]?.agent_run_id) === tab.agent_run_id;
return (
<button
key={tab.agent_run_id}
type="button"
role="tab"
aria-selected={active}
data-active={active}
className="rc-agent-tab"
onClick={() => onFocus(tab.agent_run_id)}
>
{statusIcon(tab, streaming, awaiting)}
<span className="max-w-32 truncate">{tab.display_name}</span>
<span className="text-[10px] text-[var(--rc-text-secondary)]">
{statusText(tab, streaming, awaiting)}
</span>
</button>
);
})}
</div>
);
});
@@ -1,22 +1,41 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowDown, ArrowUp, Ban, Hammer, Target, X } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Ban, ChevronDown, Target, X } from "lucide-react";
import { getReportCollaborationDriver } from "@/report-collaboration/api/driver";
import type { NodeContractDetail } from "@/report-collaboration/api/types";
import { roleIcon, type ReportFlowNodeKind } from "@/report-collaboration/components/ReportFlowNode";
export interface NodeInspectorFallback {
label: string;
status: string;
role?: string | null;
angle?: string | null;
kind?: ReportFlowNodeKind;
mission?: string | null;
input_artifact_types?: string[];
output_artifact_type?: string | null;
allowed_tools?: string[];
acceptance_criteria?: string[];
upstream?: string[];
downstream?: string[];
}
interface NodeInspectorDrawerProps {
open: boolean;
nodeId: string | null;
runId: string | null;
/** 节点基础信息(画布侧已有,用于头部展示,不发起结果请求)。 */
fallback?: { label: string; status: string } | null;
/** 节点基础信息(画布侧已有,预览方案时也能填抽屉,不发起结果请求)。 */
fallback?: NodeInspectorFallback | null;
onClose: () => void;
/** 「在对话中对此节点提出意见」:关闭抽屉 + 聚焦 Composer + 绑定目标。 */
onComment: (nodeId: string, nodeName: string) => void;
}
const SECTION_KEYS = ["mission", "input", "output", "tools", "graph"] as const;
type SectionKey = (typeof SECTION_KEYS)[number];
/**
* 只读节点说明抽屉:覆盖在画布右侧,不占第三栏。
* 只读取节点合同(职责/输入/产出/工具/验收),不请求节点执行结果;
@@ -33,6 +52,13 @@ export function NodeInspectorDrawer({
const [contract, setContract] = useState<NodeContractDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
mission: true,
input: true,
output: true,
tools: true,
graph: true,
});
useEffect(() => {
if (!open || !nodeId || !runId) {
@@ -59,135 +85,182 @@ export function NodeInspectorDrawer({
};
}, [open, nodeId, runId]);
const view = useMemo(() => {
return {
label: contract?.label ?? fallback?.label ?? nodeId ?? "",
status: contract?.status ?? fallback?.status ?? "planned",
role: contract?.role ?? fallback?.role ?? null,
angle: contract?.angle ?? fallback?.angle ?? null,
kind: fallback?.kind ?? "agent",
mission: contract?.mission ?? fallback?.mission ?? null,
input_artifact_types: contract?.input_artifact_types ?? fallback?.input_artifact_types ?? [],
output_artifact_type: contract?.output_artifact_type ?? fallback?.output_artifact_type ?? null,
allowed_tools: contract?.allowed_tools ?? fallback?.allowed_tools ?? [],
acceptance_criteria: contract?.acceptance_criteria ?? fallback?.acceptance_criteria ?? [],
upstream: contract?.upstream ?? fallback?.upstream ?? [],
downstream: contract?.downstream ?? fallback?.downstream ?? [],
forbidden: contract?.forbidden ?? [],
max_attempts: contract?.max_attempts ?? null,
time_budget_seconds: contract?.time_budget_seconds ?? null,
token_budget: contract?.token_budget ?? null,
source_policy: contract?.source_policy ?? null,
};
}, [contract, fallback, nodeId]);
if (!open || !nodeId) return null;
const label = contract?.label ?? fallback?.label ?? nodeId;
const status = contract?.status ?? fallback?.status ?? "planned";
const hasBody = Boolean(contract) || Boolean(fallback?.mission) || Boolean(fallback?.label);
const showLoading = loading && !hasBody;
const toggle = (key: SectionKey) => {
setOpenSections((current) => ({ ...current, [key]: !current[key] }));
};
return (
<>
<div className="rc-drawer-mask" onClick={onClose} aria-hidden />
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${label}`}>
<header className="rc-drawer-section flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold">{label}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-[var(--rc-text-secondary)]">
{contract?.role ? <span>{contract.role}</span> : null}
{contract?.angle ? <span>· {contract.angle}</span> : null}
<span>· 当前状态:{statusText(status)}</span>
</div>
<aside className="rc-drawer" role="dialog" aria-label={`节点说明:${view.label}`}>
<header className="rc-drawer-header">
<span className="rc-flow-node-icon" aria-hidden>
{roleIcon(view.role, view.kind)}
</span>
<div className="min-w-0 flex-1">
<div className="rc-drawer-title">{view.label}</div>
<div className="rc-drawer-subtitle">
{view.role ? <span>{view.role}</span> : null}
{view.angle ? <span>· {view.angle}</span> : null}
<span>· {statusText(view.status)}</span>
</div>
<button
type="button"
className="rc-btn shrink-0 !min-h-8 !px-2"
onClick={onClose}
aria-label="关闭节点说明"
>
<X className="size-4" />
</button>
</header>
</div>
<button type="button" className="rc-drawer-icon-btn" onClick={onClose} aria-label="关闭节点说明">
<X className="size-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
) : error ? (
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
读取节点合同失败:{error}
</div>
) : contract ? (
<>
<section className="rc-drawer-section">
<div className="rc-drawer-label">节点职责</div>
<p className="text-[12px] leading-5">{contract.mission}</p>
</section>
<section className="rc-drawer-section">
<div className="rc-drawer-label flex items-center gap-1">
<ArrowDown className="size-3" aria-hidden /> 要根据什么
</div>
<ul className="list-disc pl-4 text-[12px] leading-5">
{contract.input_artifact_types.length > 0 ? (
contract.input_artifact_types.map((type) => <li key={type}>输入成果:{type}</li>)
<div className="rc-drawer-body">
{showLoading ? (
<div className="rc-empty py-10 text-[12px]">正在读取节点合同…</div>
) : error && !hasBody ? (
<div className="rc-empty py-10 text-[12px]" style={{ color: "var(--rc-danger)" }}>
读取节点合同失败:{error}
</div>
) : (
<>
<DrawerSection title="节点职责" open={openSections.mission} onToggle={() => toggle("mission")}>
<p className="rc-drawer-text">{view.mission || "暂无职责说明"}</p>
</DrawerSection>
<DrawerSection title="输入" open={openSections.input} onToggle={() => toggle("input")}>
<ul className="rc-drawer-list">
{view.input_artifact_types.length > 0 ? (
view.input_artifact_types.map((type) => (
<li key={type}>
<span className="rc-flow-pill rc-flow-pill-input">{shortArtifact(type)}</span>
</li>
))
) : (
<li className="text-[var(--rc-text-secondary)]">无上游成果要求</li>
)}
{view.upstream.length > 0 ? <li>上游节点:{view.upstream.join("、")}</li> : null}
{view.source_policy ? <li>资料策略:{view.source_policy}</li> : null}
</ul>
</DrawerSection>
<DrawerSection title="输出" open={openSections.output} onToggle={() => toggle("output")}>
<ul className="rc-drawer-list">
<li>
成果类型:
{view.output_artifact_type ? (
<span className="rc-flow-pill rc-flow-pill-output">{shortArtifact(view.output_artifact_type)}</span>
) : (
<li>无上游成果要求</li>
"未指定"
)}
{contract.upstream.length > 0 ? (
<li>上游节点:{contract.upstream.join("、")}</li>
) : null}
{contract.source_policy ? <li>资料策略:{contract.source_policy}</li> : null}
</ul>
</section>
<section className="rc-drawer-section">
<div className="rc-drawer-label flex items-center gap-1">
<ArrowUp className="size-3" aria-hidden /> 要产出什么
</div>
<ul className="list-disc pl-4 text-[12px] leading-5">
<li>成果类型:{contract.output_artifact_type}</li>
{contract.acceptance_criteria.map((criterion) => (
<li key={criterion}>验收:{criterion}</li>
</li>
{view.acceptance_criteria.map((criterion) => (
<li key={criterion}>验收:{criterion}</li>
))}
</ul>
</DrawerSection>
<DrawerSection title="技能" open={openSections.tools} onToggle={() => toggle("tools")}>
<div className="flex flex-wrap gap-1">
{view.allowed_tools.length > 0 ? (
view.allowed_tools.map((tool) => (
<span key={tool} className="rc-flow-pill rc-flow-pill-neutral">
{tool}
</span>
))
) : (
<span className="text-[12px] text-[var(--rc-text-secondary)]">未配置技能</span>
)}
</div>
{view.forbidden.length > 0 ? (
<ul className="rc-drawer-list mt-2">
{view.forbidden.map((rule) => (
<li key={rule}>
<Ban className="mr-1 inline size-3" aria-hidden />
{rule}
</li>
))}
</ul>
</section>
<section className="rc-drawer-section">
<div className="rc-drawer-label flex items-center gap-1">
<Hammer className="size-3" aria-hidden /> 可用工具与边界
</div>
<div className="flex flex-wrap gap-1">
{contract.allowed_tools.length > 0 ? (
contract.allowed_tools.map((tool) => (
<span key={tool} className="rc-chip">
{tool}
</span>
))
) : (
<span className="text-[12px] text-[var(--rc-text-secondary)]">无外部工具</span>
)}
</div>
{contract.forbidden && contract.forbidden.length > 0 ? (
<ul className="mt-2 list-disc pl-4 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
{contract.forbidden.map((rule) => (
<li key={rule}>
<Ban className="mr-1 inline size-3" aria-hidden />
{rule}
</li>
))}
</ul>
) : null}
<p className="mt-2 text-[11px] text-[var(--rc-text-secondary)]">
预算:至多 {contract.max_attempts} 次尝试
{contract.time_budget_seconds ? ` · ${contract.time_budget_seconds}s` : ""}
{contract.token_budget ? ` · ${contract.token_budget} tokens` : ""}
) : null}
{view.max_attempts != null ? (
<p className="mt-2 text-[12px] text-[var(--rc-text-secondary)]">
预算:至多 {view.max_attempts} 次尝试
{view.time_budget_seconds ? ` · ${view.time_budget_seconds}s` : ""}
{view.token_budget ? ` · ${view.token_budget} tokens` : ""}
</p>
</section>
<section className="rc-drawer-section">
<div className="rc-drawer-label">上下游关系</div>
<p className="text-[12px] leading-5">
{contract.downstream.length > 0
? `下游:${contract.downstream.join("、")}`
: "终端节点(无下游)"}
</p>
</section>
</>
) : null}
</div>
) : null}
</DrawerSection>
<DrawerSection title="上下游" open={openSections.graph} onToggle={() => toggle("graph")}>
<p className="rc-drawer-text">
{view.downstream.length > 0 ? `下游:${view.downstream.join("、")}` : "终端节点(无下游)"}
</p>
</DrawerSection>
</>
)}
</div>
<footer className="rc-drawer-section !border-t border-b-0">
<button
type="button"
className="rc-btn rc-btn-primary w-full justify-center"
onClick={() => onComment(nodeId, label)}
>
<Target className="size-3.5" aria-hidden />
在对话中对此节点提出意见
</button>
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
</p>
</footer>
</aside>
</>
<footer className="rc-drawer-footer">
<button
type="button"
className="rc-btn rc-btn-primary w-full justify-center"
onClick={() => onComment(nodeId, view.label)}
>
<Target className="size-3.5" aria-hidden />
在对话中对此节点提出意见
</button>
<p className="mt-2 text-[11px] leading-4 text-[var(--rc-text-secondary)]">
重跑、补充或纠偏通过对话完成(例如「重新检索」「重新分析」);抽屉不直接发起变更。
</p>
</footer>
</aside>
);
}
function DrawerSection({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<section className="rc-drawer-section" data-open={open}>
<button type="button" className="rc-drawer-section-toggle" onClick={onToggle} aria-expanded={open}>
<ChevronDown className="rc-drawer-chevron" aria-hidden />
<span>{title}</span>
</button>
{open ? <div className="rc-drawer-section-body">{children}</div> : null}
</section>
);
}
function shortArtifact(value: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
return trimmed.includes(":") ? trimmed.slice(trimmed.lastIndexOf(":") + 1) : trimmed;
}
function statusText(status: string): string {
const map: Record<string, string> = {
planned: "规划中",
@@ -19,6 +19,8 @@ interface ReportConversationPanelProps {
composer: React.ReactNode;
loading: boolean;
emptyHint?: React.ReactNode;
/** 并行切换成员时重挂消息列表,避免沿用上一成员的滚动位置。 */
listKey?: string;
}
/**
@@ -33,6 +35,7 @@ export function ReportConversationPanel({
composer,
loading,
emptyHint,
listKey,
}: ReportConversationPanelProps) {
const hasMessages = thread.messages.length > 0;
@@ -51,6 +54,7 @@ export function ReportConversationPanel({
return (
<ThreadContext.Provider value={{ thread, isMock: true }}>
<MessageList
key={listKey ?? sessionId}
className="min-h-0 flex-1"
threadId={sessionId}
thread={thread}
@@ -60,7 +64,7 @@ export function ReportConversationPanel({
/>
</ThreadContext.Provider>
);
}, [businessCardSlot, emptyHint, hasMessages, loading, sessionId, thread]);
}, [businessCardSlot, emptyHint, hasMessages, listKey, loading, sessionId, thread]);
return (
<SubtasksProvider>
@@ -2,12 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
Position,
ReactFlow,
ReactFlowProvider,
type DefaultEdgeOptions,
type Edge,
type Node,
type NodeTypes,
@@ -23,11 +23,21 @@ import type {
import {
CollaborationFlowNode,
type ReportFlowNodeDatum,
type ReportFlowNodeKind,
} from "@/report-collaboration/components/ReportFlowNode";
const NODE_TYPES: NodeTypes = { collaboration: CollaborationFlowNode };
const COLUMN_GAP = 280;
const ROW_GAP = 130;
/** 左到右 rank 间距(节点左缘到下一列左缘),对齐 Coze 整理布局的横向留白。 */
const COLUMN_GAP = 360;
/** 并行节点高度估算(去掉输入/输出后的卡片高度),用于重心对齐。 */
const NODE_HEIGHT = 128;
/** 同层并行节点之间的垂直间隙,对齐 Coze Dagre nodesep 观感。 */
const NODE_GAP = 52;
const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {
type: "default",
style: { stroke: "var(--rc-flow-edge)", strokeWidth: 2 },
};
interface ReportFlowCanvasProps {
plan: ReportPlanCandidate | null;
@@ -38,15 +48,16 @@ interface ReportFlowCanvasProps {
emptyHint?: string;
}
/** 分层自动布局:depth = 最长依赖链长度;同层纵向排列。 */
/** 分层 + 重心对齐:同层并行节点绕父节点垂直中心对称,对齐 Coze「整理节点」Dagre LR。 */
function layoutPlan(plan: ReportPlanCandidate): Map<string, { x: number; y: number }> {
const depthById = new Map<string, number>();
const nodeIds = new Set(plan.nodes.map((node) => node.id));
const nodeById = new Map(plan.nodes.map((node) => [node.id, node]));
const nodeIds = new Set(nodeById.keys());
const visit = (nodeId: string, seen: Set<string>): number => {
if (depthById.has(nodeId)) return depthById.get(nodeId)!;
if (seen.has(nodeId)) return 0; // 防御环
if (seen.has(nodeId)) return 0;
seen.add(nodeId);
const node = plan.nodes.find((candidate) => candidate.id === nodeId);
const node = nodeById.get(nodeId);
const depth =
node && node.depends_on.length > 0
? Math.max(...node.depends_on.filter((dep) => nodeIds.has(dep)).map((dep) => visit(dep, seen))) + 1
@@ -62,11 +73,30 @@ function layoutPlan(plan: ReportPlanCandidate): Map<string, { x: number; y: numb
layers.set(depth, [...(layers.get(depth) ?? []), node.id]);
}
const positions = new Map<string, { x: number; y: number }>();
for (const [depth, ids] of layers) {
ids.forEach((id, index) => {
positions.set(id, { x: depth * COLUMN_GAP, y: index * ROW_GAP });
const yById = new Map<string, number>();
const spacing = NODE_HEIGHT + NODE_GAP;
const depths = [...layers.keys()].sort((a, b) => a - b);
for (const depth of depths) {
const ids = layers.get(depth) ?? [];
const desired = ids.map((id) => {
const node = nodeById.get(id);
const parents = (node?.depends_on ?? []).filter((dep) => yById.has(dep));
if (parents.length === 0) return 0;
return parents.reduce((sum, parentId) => sum + (yById.get(parentId) ?? 0), 0) / parents.length;
});
const packed = ids.map((_, index) => index * spacing);
const packedMean = packed.reduce((sum, value) => sum + value, 0) / Math.max(packed.length, 1);
const desiredMean = desired.reduce((sum, value) => sum + value, 0) / Math.max(desired.length, 1);
const shift = desiredMean - packedMean;
ids.forEach((id, index) => {
yById.set(id, packed[index] + shift);
});
}
const positions = new Map<string, { x: number; y: number }>();
for (const node of plan.nodes) {
const depth = depthById.get(node.id) ?? 0;
positions.set(node.id, { x: depth * COLUMN_GAP, y: yById.get(node.id) ?? 0 });
}
return positions;
}
@@ -93,8 +123,11 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
const nodes: Node[] = useMemo(() => {
if (!plan) return [];
void draggedVersion;
const outgoing = new Set(plan.edges.map((edge) => edge.source));
return plan.nodes.map((node) => {
const state = nodeStates[node.id];
const kind: ReportFlowNodeKind =
node.depends_on.length === 0 ? "start" : outgoing.has(node.id) ? "agent" : "end";
const datum: ReportFlowNodeDatum = {
nodeId: node.id,
label: node.label,
@@ -103,6 +136,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
status: state?.status ?? "planned",
attempt: state?.attempt ?? 0,
selected: selectedNodeId === node.id,
kind,
tools: node.allowed_tools ?? [],
};
const dragged = draggedRef.current.get(node.id);
const position = dragged ?? autoPositions.get(node.id) ?? { x: 0, y: 0 };
@@ -112,6 +147,8 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
position,
data: datum,
draggable: true,
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
}, [plan, nodeStates, selectedNodeId, autoPositions, draggedVersion]);
@@ -126,9 +163,13 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
source: edge.source,
target: edge.target,
label: edge.label ?? undefined,
type: "default",
animated: false,
className: active ? "rc-edge-active" : undefined,
style: { stroke: "var(--rc-border-strong)", strokeWidth: 1.5 },
className: active ? "rc-edge-active" : "rc-edge",
style: {
stroke: active ? "var(--rc-running)" : "var(--rc-flow-edge)",
strokeWidth: 2,
},
};
});
}, [plan, nodeStates]);
@@ -144,9 +185,11 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
return (
<ReactFlow
className="rc-flow"
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
onNodeClick={(_, node) => onSelectNode(node.id)}
onPaneClick={() => onSelectNode(null)}
onNodeDragStop={(_, node) => {
@@ -156,22 +199,21 @@ function CanvasInner({ plan, nodeStates, selectedNodeId, onSelectNode, emptyHint
onNodeDoubleClick={(_, node) => onSelectNode(node.id)}
fitView
fitViewOptions={{ padding: 0.18 }}
minZoom={0.25}
minZoom={0.55}
maxZoom={1.75}
nodesConnectable={false}
elementsSelectable
proOptions={{ hideAttribution: true }}
aria-label="协作流程画布"
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="var(--rc-border-strong)" />
<Controls position="bottom-left" showInteractive={false} />
<Controls position="bottom-left" orientation="horizontal" showInteractive={false} />
<MiniMap
position="bottom-right"
pannable
zoomable
style={{ border: "1px solid var(--rc-border)", borderRadius: 8 }}
nodeColor={() => "var(--rc-border-strong)"}
maskColor="rgba(247, 247, 252, 0.7)"
className="rc-flow-minimap"
nodeColor={() => "var(--rc-flow-edge)"}
maskColor="var(--rc-flow-minimap-mask)"
/>
</ReactFlow>
);
@@ -1,19 +1,30 @@
"use client";
import { memo } from "react";
import { memo, type ReactNode } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
AlertTriangle,
Bot,
Check,
CircleArrowRight,
CircleDashed,
FileCheck,
Flag,
LineChart,
Loader2,
PenLine,
RefreshCw,
Search,
ShieldCheck,
ShieldQuestion,
Users,
X,
} from "lucide-react";
import type { CollaborationNodeStatus } from "@/report-collaboration/api/types";
export type ReportFlowNodeKind = "start" | "end" | "agent";
/** 画布自定义节点数据(由 ReportFlowCanvas 组装)。 */
export interface ReportFlowNodeDatum {
nodeId: string;
@@ -23,6 +34,8 @@ export interface ReportFlowNodeDatum {
status: CollaborationNodeStatus;
attempt: number;
selected: boolean;
kind: ReportFlowNodeKind;
tools: string[];
/** xyflow Node.data 需要 Record 兼容的索引签名。 */
[key: string]: unknown;
}
@@ -31,7 +44,7 @@ export type ReportFlowNodeType = "collaboration";
const STATUS_META: Record<
CollaborationNodeStatus,
{ text: string; color: string; icon: React.ReactNode }
{ text: string; color: string; icon: ReactNode }
> = {
planned: { text: "规划中", color: "var(--rc-text-secondary)", icon: <CircleDashed className="size-3.5" /> },
queued: { text: "排队中", color: "var(--rc-text-secondary)", icon: <CircleDashed className="size-3.5" /> },
@@ -47,33 +60,89 @@ const STATUS_META: Record<
interrupted: { text: "已中断", color: "var(--rc-waiting)", icon: <AlertTriangle className="size-3.5" /> },
};
export function roleIcon(roleName: string | null | undefined, kind: ReportFlowNodeKind = "agent"): ReactNode {
if (kind === "start") return <CircleArrowRight />;
if (kind === "end") return <Flag />;
switch (roleName) {
case "协调者":
return <Users />;
case "研究员":
return <Search />;
case "核验员":
return <ShieldCheck />;
case "分析员":
return <LineChart />;
case "撰写员":
return <PenLine />;
case "审稿员":
return <FileCheck />;
default:
return <Bot />;
}
}
function FlowPill({ children }: { children: ReactNode }) {
return <span className="rc-flow-pill rc-flow-pill-neutral">{children}</span>;
}
function FieldRow({ label, children }: { label: string; children: ReactNode }) {
return (
<>
<div className="rc-flow-node-field-label">{label}</div>
<div className="rc-flow-node-field-value">{children}</div>
</>
);
}
function CollaborationFlowNodeComponent({ data }: NodeProps) {
const node = data as unknown as ReportFlowNodeDatum;
const meta = STATUS_META[node.status] ?? STATUS_META.planned;
const statusText = node.attempt > 1 ? `${meta.text} · 第 ${node.attempt} 次` : meta.text;
return (
<div
className="rc-flow-node"
data-status={node.status}
data-selected={node.selected}
data-kind={node.kind}
data-role={node.roleName ?? ""}
tabIndex={0}
role="button"
aria-label={`节点 ${node.label},${meta.text}`}
aria-label={`节点 ${node.label},${statusText}`}
>
<Handle type="target" position={Position.Top} className="!bg-[var(--rc-border-strong)]" isConnectable={false} />
<div className="rc-flow-node-title">{node.label}</div>
{(node.roleName || node.angle) && (
<div className="rc-flow-node-sub truncate">
{[node.roleName, node.angle].filter(Boolean).join(" · ")}
</div>
)}
<div className="rc-flow-node-status" style={{ color: meta.color }}>
{meta.icon}
<span>
{meta.text}
{node.attempt > 1 ? ` · 第 ${node.attempt} 次` : ""}
<Handle type="target" position={Position.Left} className="rc-flow-handle" isConnectable={false} />
<div className="rc-flow-node-header">
<span className="rc-flow-node-icon" aria-hidden>
{roleIcon(node.roleName, node.kind)}
</span>
<span className="rc-flow-node-title">{node.label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-[var(--rc-border-strong)]" isConnectable={false} />
<div className="rc-flow-node-body">
{node.roleName ? (
<FieldRow label="角色">
<FlowPill>{node.roleName}</FlowPill>
</FieldRow>
) : null}
{node.angle ? (
<FieldRow label="角度">
<FlowPill>{node.angle}</FlowPill>
</FieldRow>
) : null}
{node.tools.length > 0 ? (
<FieldRow label="技能">
{node.tools.map((tool) => (
<FlowPill key={`tool-${tool}`}>{tool}</FlowPill>
))}
</FieldRow>
) : null}
<FieldRow label="状态">
<span className="rc-flow-node-status" style={{ color: meta.color }}>
{meta.icon}
<span>{statusText}</span>
</span>
</FieldRow>
</div>
<Handle type="source" position={Position.Right} className="rc-flow-handle" isConnectable={false} />
</div>
);
}
@@ -1,7 +1,7 @@
/**
* 消息组装 hook:状态 → 聚焦过滤 → LangGraph Message[] → BaseStream 外观。
*
* - 并行聚焦只做客户端过滤(共享 human + 目标成员消息),不重拉不改写。
* - 并行执行中只显示当前成员;结束后把各成员收成普通聊天记录,不重拉不改写。
* - 虚拟业务卡(方案组 / 命令确认 / 报告入口)由页面以 extraCards 传入,
* 经 MessageList 的 businessCardSlot 渲染。
*/
@@ -18,7 +18,7 @@ import {
buildBaseStreamFacade,
toLangGraphMessages,
} from "@/report-collaboration/state/message-adapter";
import { selectMessagesForAgentRun, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors";
import { selectConversationMessages, selectOrderedVisibleMessages } from "@/report-collaboration/state/selectors";
export interface UseReportCollaborationMessagesResult {
visibleMessages: CollaborationMessage[];
@@ -37,11 +37,12 @@ export function useReportCollaborationMessages(
): UseReportCollaborationMessagesResult {
return useMemo(() => {
const visibleMessages = selectOrderedVisibleMessages(state);
const focusedMessages = selectMessagesForAgentRun(visibleMessages, focusedAgentRunId);
const focusedMessages = selectConversationMessages(state, focusedAgentRunId);
const base = toLangGraphMessages(focusedMessages);
const displayMessages = extraCards.length > 0 ? [...base, ...extraCards] : base;
const streamSource = focusedAgentRunId ? focusedMessages : visibleMessages;
const streaming =
visibleMessages.some((message) => !message.completed_at && message.role !== "human") ||
streamSource.some((message) => !message.completed_at && message.role !== "human") ||
extraLoading;
const displayThread = buildBaseStreamFacade(displayMessages, streaming);
return { visibleMessages, focusedMessages, displayMessages, displayThread, streaming };
@@ -41,6 +41,7 @@ import {
selectParallelMembers,
selectPendingConfirmationCommands,
selectPlanForCanvas,
selectPreferredParallelFocus,
selectStreamingAgentRunIds,
} from "@/report-collaboration/state/selectors";
import { buildBaseStreamFacade } from "@/report-collaboration/state/message-adapter";
@@ -165,7 +166,14 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
const agentTabs = useMemo(() => selectAgentTabs(state), [state]);
const [focusedAgentRunId, setFocusedAgentRunId] = useState<string | null>(null);
const [focusPinned, setFocusPinned] = useState(false);
const effectiveFocus = agentTabs.length > 0 ? (focusedAgentRunId ?? agentTabs[0].agent_run_id) : null;
const tabIds = useMemo(() => new Set(agentTabs.map((tab) => tab.agent_run_id)), [agentTabs]);
const effectiveFocus =
agentTabs.length === 0
? null
: focusedAgentRunId && tabIds.has(focusedAgentRunId)
? focusedAgentRunId
: agentTabs[0].agent_run_id;
const focusedTab = agentTabs.find((tab) => tab.agent_run_id === effectiveFocus) ?? null;
const { previewPlanId, previewPlan, effectivePlanId } = useReportPlanSelection(
state.plans,
@@ -208,6 +216,18 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.actionError]);
// 并行波次:默认落到正在执行的成员;点头像/画布后保持该成员,直到波次结束。
useEffect(() => {
if (agentTabs.length === 0) {
if (focusedAgentRunId) setFocusedAgentRunId(null);
if (focusPinned) setFocusPinned(false);
return;
}
if (focusedAgentRunId && tabIds.has(focusedAgentRunId)) return;
const next = selectPreferredParallelFocus(agentTabs);
if (next) setFocusedAgentRunId(next);
}, [agentTabs, focusPinned, focusedAgentRunId, tabIds]);
// 虚拟业务卡:方案组(已出方案且 run 未开始)/ 命令确认 / 报告入口。
// 采用方案后卡片保留,「开始协作」按钮随选定态启用;run 开始后消失。
const runStarted = Boolean(state.run && state.run.plan_id);
@@ -271,30 +291,30 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
(nodeId: string | null) => {
setSelectedNodeId(nodeId);
setDrawerOpen(Boolean(nodeId));
// 打开并行成员节点时同步聚焦对应标签(用户发送前先看到该成员的真实对话)。
// 点并行智能体节点时,消息列表切到该成员的执行过程(与头像条同一套聚焦)。
if (nodeId) {
const member = findMemberOfNode(nodeId);
if (member) {
if (member && tabIds.has(member.agent_run_id)) {
setFocusedAgentRunId(member.agent_run_id);
setFocusPinned(true);
}
}
},
[findMemberOfNode],
[findMemberOfNode, tabIds],
);
const handleNodeComment = useCallback(
(nodeId: string, nodeName: string) => {
const member = findMemberOfNode(nodeId);
setComposerTarget({ nodeId, nodeName, agentRunId: member?.agent_run_id ?? null });
if (member) {
if (member && tabIds.has(member.agent_run_id)) {
setFocusedAgentRunId(member.agent_run_id);
setFocusPinned(true);
}
setDrawerOpen(false);
setFocusSignal((signal) => signal + 1);
},
[findMemberOfNode],
[findMemberOfNode, tabIds],
);
const handleRestore = useCallback(
@@ -363,10 +383,26 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
const runActive = isRunActive(state.run?.status);
const headVersion = selectHeadReportVersion(state);
const nodeStates = selectNodeStatusMap(state);
const selectedPlanNode = canvasPlan?.nodes.find((node) => node.id === selectedNodeId) ?? null;
const outgoingIds = new Set(canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? []);
const drawerFallback = selectedNodeId
? {
label: state.nodes[selectedNodeId]?.label ?? canvasPlan?.nodes.find((n) => n.id === selectedNodeId)?.label ?? selectedNodeId,
label: state.nodes[selectedNodeId]?.label ?? selectedPlanNode?.label ?? selectedNodeId,
status: state.nodes[selectedNodeId]?.status ?? "planned",
role: state.nodes[selectedNodeId]?.role_display_name ?? selectedPlanNode?.role_display_name ?? null,
angle: state.nodes[selectedNodeId]?.angle ?? selectedPlanNode?.angle ?? null,
kind: (selectedPlanNode && selectedPlanNode.depends_on.length === 0
? "start"
: outgoingIds.size > 0
? "agent"
: "end") as const,
mission: selectedPlanNode?.mission ?? null,
input_artifact_types: selectedPlanNode?.input_artifact_types ?? [],
output_artifact_type: selectedPlanNode?.output_artifact_type ?? null,
allowed_tools: selectedPlanNode?.allowed_tools ?? [],
acceptance_criteria: selectedPlanNode?.acceptance_criteria ?? [],
upstream: selectedPlanNode?.depends_on ?? [],
downstream: canvasPlan?.edges.filter((edge) => edge.source === selectedNodeId).map((edge) => edge.target) ?? [],
}
: null;
@@ -434,10 +470,12 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
sessionId={sessionId}
thread={displayThread}
businessCardSlot={businessCardSlot}
listKey={effectiveFocus ?? "timeline"}
loading={session.snapshotLoading}
stageSelector={
<AgentStageSelector
tabs={agentTabs}
phaseLabel={`并行 ${agentTabs.length} 席`}
focusedAgentRunId={effectiveFocus}
onFocus={(agentRunId) => {
setFocusedAgentRunId(agentRunId);
@@ -447,6 +485,11 @@ export function ReportCollaborationSessionPage({ sessionId }: { sessionId: strin
awaitingAgentRunIds={awaitingIds}
/>
}
emptyHint={
effectiveFocus
? `${focusedTab?.display_name ?? "智能体"}正在执行,输出会出现在这里`
: undefined
}
composer={
<ReportComposer
phase={phase}
@@ -23,48 +23,155 @@ export function selectOrderedVisibleMessages(state: CollaborationState): Collabo
}
/**
* 并行成员聚焦:null = 全量(单成员 / 协调者阶段);指定 agentRunId 时
* 只保留「共享消息(human + 无归属 ai/tool)+ 目标成员消息」。
* 只做客户端过滤,不重拉、不重排、不改写。
* 并行成员聚焦:null = 全量时间线(非并行 / 单成员阶段)。
* 指定 agentRunId 时只保留该成员自己的消息(含指向该成员的 human),
* 不混入协调者、其它并行成员或更早的共享对话。
*/
export function selectMessagesForAgentRun(
messages: CollaborationMessage[],
focusedAgentRunId: string | null,
): CollaborationMessage[] {
if (!focusedAgentRunId) return messages;
return messages.filter((message) => {
if (message.role === "human") return true;
return !message.agent_run_id || message.agent_run_id === focusedAgentRunId;
});
return messages.filter((message) => message.agent_run_id === focusedAgentRunId);
}
function isCoordinator(agentRun: AgentRun): boolean {
return /coord/i.test(agentRun.role) || /协调/.test(agentRun.display_name);
}
/** 并行阶段成员(不含协调者);同一 phase 下 >= 2 个成员时显示选择器。 */
export function selectParallelMembers(state: CollaborationState): AgentRun[] {
const runs = Object.values(state.agentRuns);
const members = runs.filter((run) => !isCoordinator(run));
if (members.length < 2) return [];
// 以最新 phase 聚合(并行阶段成员共享 phase_id)。
const latestPhase = members
.map((run) => run.phase_id ?? "")
.sort()
.at(-1);
const phaseMembers = members.filter((run) => (run.phase_id ?? "") === latestPhase);
return phaseMembers.length >= 2 ? phaseMembers : [];
const IN_FLIGHT_AGENT_STATUS = new Set(["pending", "running", "waiting_input"]);
const IN_FLIGHT_NODE_STATUS = new Set([
"queued",
"running",
"validating",
"awaiting_input",
"cancel_requested",
"reopened",
]);
const TERMINAL_NODE_STATUS = new Set(["completed", "failed", "cancelled", "superseded", "interrupted"]);
const TERMINAL_AGENT_STATUS = new Set(["completed", "failed", "cancelled"]);
function nodeForAgent(state: CollaborationState, agentRun: AgentRun): CollaborationNodeState | null {
const nodeRunId = agentRun.node_run_id;
if (!nodeRunId) return null;
for (const node of Object.values(state.nodes)) {
if (nodeRunId === node.node_id || nodeRunId.startsWith(`${node.node_id}-`)) {
return node;
}
}
return null;
}
/** 选择器标签:协调者在首位,后跟并行成员(仅并行阶段非空时使用)。 */
/** 该成员是否仍在执行:节点终态优先于 agentRun.status(画布完成即结束)。 */
export function isAgentStillExecuting(state: CollaborationState, agentRun: AgentRun): boolean {
const node = nodeForAgent(state, agentRun);
if (node) {
if (TERMINAL_NODE_STATUS.has(node.status)) return false;
if (IN_FLIGHT_NODE_STATUS.has(node.status)) return true;
}
if (TERMINAL_AGENT_STATUS.has(agentRun.status)) return false;
return IN_FLIGHT_AGENT_STATUS.has(agentRun.status);
}
function membersByPhase(state: CollaborationState): Map<string, AgentRun[]> {
const byPhase = new Map<string, AgentRun[]>();
for (const member of Object.values(state.agentRuns)) {
if (isCoordinator(member)) continue;
const key = member.phase_id ?? "";
const list = byPhase.get(key);
if (list) list.push(member);
else byPhase.set(key, [member]);
}
return byPhase;
}
function latestStartedAt(members: AgentRun[]): string {
return [...members].map((run) => run.started_at ?? "").sort().at(-1) ?? "";
}
/**
* 当前正在并行执行的那一波成员(不含协调者)。
* 同一 phase 下 ≥2 个成员且至少一人仍在执行时才返回;波次结束后回到空数组,
* 消息列表按会商第二步收成普通聊天记录。
*/
export function selectParallelMembers(state: CollaborationState): AgentRun[] {
const waves = [...membersByPhase(state).entries()]
.map(([phaseId, list]) => ({ phaseId, list }))
.filter(({ list }) => list.length >= 2 && list.some((member) => isAgentStillExecuting(state, member)));
if (waves.length === 0) return [];
const currentPhase = state.run?.current_phase ?? null;
const chosen =
waves.find((wave) => wave.phaseId === currentPhase) ??
[...waves].sort((a, b) => latestStartedAt(b.list).localeCompare(latestStartedAt(a.list)))[0];
return [...chosen.list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
}
/** 头像条:仅当前并行波次的执行成员(不含协调者)。 */
export function selectAgentTabs(state: CollaborationState): AgentRun[] {
const members = selectParallelMembers(state);
if (members.length === 0) return [];
const coordinator =
Object.values(state.agentRuns).find(isCoordinator) ?? null;
const byStart = (a: AgentRun, b: AgentRun) =>
(a.started_at ?? "").localeCompare(b.started_at ?? "");
return [...(coordinator ? [coordinator] : []), ...[...members].sort(byStart)];
return selectParallelMembers(state);
}
/** 波次刚出现或当前聚焦已失效时,优先落到正在输出的成员。 */
export function selectPreferredParallelFocus(
members: AgentRun[],
streamingIds: Set<string> = new Set(),
): string | null {
if (members.length === 0) return null;
const preferred =
members.find((member) => member.status === "running" || streamingIds.has(member.agent_run_id)) ??
members.find((member) => member.status === "waiting_input") ??
members[0];
return preferred.agent_run_id;
}
/**
* 并行结束后把同一波次各成员的消息收成连续块,好让 MessageList
* 把每个智能体当成一条普通聊天记录(对齐会商第二步收起并行后的列表)。
*/
export function stitchParallelAgentBlocks(
messages: CollaborationMessage[],
state: CollaborationState,
): CollaborationMessage[] {
const phases = [...membersByPhase(state).entries()].filter(([, list]) => list.length >= 2);
if (phases.length === 0) return messages;
const phaseOfAgent = new Map<string, string>();
const agentsOfPhase = new Map<string, AgentRun[]>();
for (const [phaseId, list] of phases) {
const ordered = [...list].sort((a, b) => (a.started_at ?? "").localeCompare(b.started_at ?? ""));
agentsOfPhase.set(phaseId, ordered);
for (const member of ordered) {
phaseOfAgent.set(member.agent_run_id, phaseId);
}
}
const flushed = new Set<string>();
const result: CollaborationMessage[] = [];
for (const message of messages) {
const phaseId = message.agent_run_id ? phaseOfAgent.get(message.agent_run_id) : undefined;
if (!phaseId) {
result.push(message);
continue;
}
if (flushed.has(phaseId)) continue;
flushed.add(phaseId);
for (const member of agentsOfPhase.get(phaseId) ?? []) {
for (const item of messages) {
if (item.agent_run_id === member.agent_run_id) result.push(item);
}
}
}
return result;
}
/** 会话时间线:并行执行中按成员过滤;结束后拼成普通聊天记录。 */
export function selectConversationMessages(
state: CollaborationState,
focusedAgentRunId: string | null,
): CollaborationMessage[] {
const visible = selectOrderedVisibleMessages(state);
if (focusedAgentRunId) return selectMessagesForAgentRun(visible, focusedAgentRunId);
return stitchParallelAgentBlocks(visible, state);
}
/** 画布用计划:预览 > 已选定 > 最新提案组第一张。 */
@@ -12,7 +12,7 @@
/* Chrome 109 兼容:不使用 color-mix,透明度变体用 rgba / legacy rgb 变量表达。 */
--rc-bg-page: var(--background, #f7f7fc);
--rc-bg-panel: var(--card, #fcfcff);
--rc-bg-canvas: var(--background, #f7f7fc);
--rc-bg-canvas: #f7f8f9;
--rc-bg-secondary: var(--muted, #f0f0f7);
--rc-border: var(--border, rgba(82, 100, 154, 0.13));
--rc-border-strong: var(--border, rgba(82, 100, 154, 0.13));
@@ -28,6 +28,22 @@
--rc-panel-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
--rc-card-radius: 9px;
--rc-panel-radius: 12px;
/* Coze Studio 画布皮肤(节点/连线/端口/点阵),不改交互语义 */
--rc-flow-edge: #abb0f5;
--rc-flow-port: #4d53e8;
--rc-flow-port-idle: #9197f1;
--rc-flow-accent: #4d53e8;
--rc-flow-accent-rgb: 77 83 232;
--rc-flow-dot: #c8ccd0;
--rc-flow-dot-size: 1.5px;
--rc-flow-dot-gap: 26px;
--rc-flow-minimap-mask: rgba(247, 248, 249, 0.72);
--rc-pill-input-bg: #fff1e8;
--rc-pill-input-fg: #c2410c;
--rc-pill-output-bg: #eee9ff;
--rc-pill-output-fg: #4d53e8;
--rc-pill-neutral-bg: var(--rc-bg-secondary);
--rc-pill-neutral-fg: var(--rc-text-primary);
display: flex;
height: 100%;
@@ -90,8 +106,10 @@
flex: 1;
min-width: 0;
height: 100%;
background: var(--rc-bg-canvas);
overflow: hidden;
background-color: var(--rc-bg-canvas);
background-image: radial-gradient(var(--rc-flow-dot) var(--rc-flow-dot-size), transparent var(--rc-flow-dot-size));
background-size: var(--rc-flow-dot-gap) var(--rc-flow-dot-gap);
}
/* 分隔条:宽热区 + 4px 视觉把手,键盘可聚焦 */
@@ -277,80 +295,275 @@
cursor: not-allowed;
}
/* ── 并行成员选择器 ───────────────────────────────────────────────────── */
/* ── 并行成员头像条(对齐会商第二步)─────────────────────────────────── */
.rc-agent-tabs {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px 0;
gap: 10px;
padding: 8px 12px;
overflow-x: auto;
scrollbar-width: thin;
}
.rc-agent-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--rc-border);
border-bottom: 1px solid var(--rc-border);
background: var(--rc-bg-panel);
font-size: 12px;
color: var(--rc-text-primary);
cursor: pointer;
flex-shrink: 0;
}
.rc-agent-strip-meta {
display: flex;
flex-shrink: 0;
align-items: baseline;
gap: 4px;
font-size: 10px;
font-weight: 700;
color: var(--rc-text-secondary);
white-space: nowrap;
transition: border-color 0.16s ease, background 0.16s ease;
}
.rc-agent-tab:hover {
border-color: var(--rc-accent);
}
.rc-agent-tab[data-active="true"] {
border-color: var(--rc-accent);
background: var(--rc-accent-soft);
.rc-agent-strip-running {
color: var(--rc-accent);
font-weight: 600;
}
/* ── 画布节点 ─────────────────────────────────────────────────────────── */
.rc-agent-avatars {
display: flex;
align-items: center;
gap: 8px;
}
.rc-agent-avatar {
position: relative;
flex-shrink: 0;
width: 32px;
height: 32px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
border-radius: 999px;
box-shadow: 0 0 0 1px transparent;
transition: box-shadow 0.16s ease, opacity 0.16s ease;
}
.rc-agent-avatar:hover {
box-shadow: 0 0 0 2px var(--rc-border);
}
.rc-agent-avatar[data-active="true"] {
box-shadow: 0 0 0 2px var(--rc-accent);
}
.rc-agent-avatar[data-active="false"] .rc-agent-avatar-face,
.rc-agent-avatar:not([data-active="true"]) .rc-agent-avatar-face {
opacity: 0.82;
}
.rc-agent-avatar-ping {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgb(var(--rc-accent-rgb) / 28%);
animation: rc-avatar-ping 1.4s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.rc-agent-avatar-face {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
color: #fff;
background: var(--rc-flow-accent);
box-shadow: inset 0 0 0 1px rgba(6, 7, 9, 0.08);
}
.rc-agent-avatar-face svg {
width: 16px;
height: 16px;
}
.rc-agent-avatar[data-role="研究员"] .rc-agent-avatar-face {
background: #3b82f6;
}
.rc-agent-avatar[data-role="核验员"] .rc-agent-avatar-face {
background: #0d9488;
}
.rc-agent-avatar[data-role="分析员"] .rc-agent-avatar-face {
background: #7c3aed;
}
.rc-agent-avatar[data-role="撰写员"] .rc-agent-avatar-face {
background: #ea580c;
}
.rc-agent-avatar[data-role="审稿员"] .rc-agent-avatar-face {
background: #16a34a;
}
.rc-agent-avatar-badge {
position: absolute;
right: -1px;
bottom: -1px;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 999px;
border: 1px solid #fff;
color: #fff;
box-shadow: 0 1px 2px rgba(15, 21, 40, 0.18);
background: var(--rc-text-secondary);
}
.rc-agent-avatar-badge[data-status="running"] {
background: var(--rc-accent);
}
.rc-agent-avatar-badge[data-status="awaiting"] {
background: var(--rc-waiting);
}
.rc-agent-avatar-badge[data-status="done"] {
background: var(--rc-success);
}
.rc-agent-avatar-badge[data-status="error"] {
background: var(--rc-danger);
}
.rc-agent-avatar-badge[data-status="queued"] {
display: none;
}
.rc-agent-avatar-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #fff;
}
@keyframes rc-avatar-ping {
75%,
100% {
transform: scale(1.55);
opacity: 0;
}
}
/* ── 画布(Coze 点阵 + 节点卡 + 贝塞尔连线)──────────────────────────── */
.rc-canvas-panel .rc-flow,
.rc-canvas-panel .react-flow {
background: transparent;
}
.rc-canvas-panel .react-flow__background {
display: none;
}
.rc-canvas-panel .react-flow__node,
.rc-canvas-panel .react-flow__node.selected {
padding: 0;
background: transparent;
border: none;
box-shadow: none;
outline: none;
}
.rc-canvas-panel .react-flow__edge-path {
stroke-linecap: round;
}
.rc-canvas-panel .react-flow__edge-text {
font-size: 11px;
fill: var(--rc-text-secondary);
}
.rc-canvas-panel .react-flow__controls {
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid var(--rc-border);
border-radius: 8px;
box-shadow: var(--rc-panel-shadow);
background: var(--rc-bg-panel);
overflow: hidden;
}
.rc-canvas-panel .react-flow__controls-button {
width: 28px;
height: 28px;
border: none;
border-bottom: none;
background: transparent;
fill: var(--rc-text-secondary);
}
.rc-canvas-panel .react-flow__controls-button:hover {
background: var(--rc-bg-secondary);
}
.rc-flow-minimap {
border: 1px solid var(--rc-border) !important;
border-radius: 8px !important;
overflow: hidden;
box-shadow: var(--rc-panel-shadow);
background: var(--rc-bg-panel) !important;
}
.rc-flow-node {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 168px;
max-width: 216px;
padding: 8px 10px;
border-radius: var(--rc-card-radius);
border: 1.5px solid var(--rc-border-strong);
width: 280px;
background: var(--rc-bg-panel);
border: 1px solid var(--rc-border);
border-radius: 8px;
box-shadow: var(--rc-panel-shadow);
font-size: 12px;
cursor: pointer;
overflow: visible;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.rc-flow-node:hover {
border-color: var(--rc-accent);
border-color: var(--rc-flow-accent);
}
.rc-flow-node[data-selected="true"] {
border-color: var(--rc-flow-accent);
}
.rc-flow-node[data-status="queued"] {
outline: 2px solid var(--rc-text-secondary);
}
.rc-flow-node[data-status="running"],
.rc-flow-node[data-status="validating"] {
border-color: var(--rc-running);
}
.rc-flow-node[data-status="awaiting_input"] {
border-color: var(--rc-waiting);
outline: 2px solid var(--rc-running);
outline-offset: 2px;
box-shadow:
0 0 0 5px rgba(37, 99, 235, 0.14),
0 4px 14px rgba(37, 99, 235, 0.18);
animation: rc-node-running-pulse 1.25s ease-in-out infinite;
}
.rc-flow-node[data-status="completed"] {
border-color: var(--rc-success);
outline: 2px solid var(--rc-success);
}
.rc-flow-node[data-status="failed"] {
border-color: var(--rc-danger);
outline: 2px solid var(--rc-danger);
}
.rc-flow-node[data-status="awaiting_input"] {
outline: 2px solid var(--rc-waiting);
outline-offset: 2px;
box-shadow: 0 0 0 5px rgba(217, 119, 6, 0.14);
}
.rc-flow-node[data-status="superseded"],
@@ -358,62 +571,193 @@
opacity: 0.62;
}
.rc-flow-node[data-selected="true"] {
box-shadow: 0 0 0 2px rgb(var(--rc-accent-rgb) / 26%);
.rc-flow-node-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 12px 12px 8px;
background: var(--rc-bg-panel);
border-radius: 8px 8px 0 0;
}
.rc-flow-node[data-kind="start"] .rc-flow-node-header,
.rc-flow-node[data-kind="end"] .rc-flow-node-header {
background: linear-gradient(180deg, rgb(var(--rc-flow-accent-rgb) / 12%) 0%, var(--rc-bg-panel) 100%);
}
.rc-flow-node-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 4px;
background: var(--rc-flow-accent);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(6, 7, 9, 0.08);
}
.rc-flow-node-icon svg {
width: 14px;
height: 14px;
}
.rc-flow-node[data-role="研究员"] .rc-flow-node-icon {
background: #3b82f6;
}
.rc-flow-node[data-role="核验员"] .rc-flow-node-icon {
background: #0d9488;
}
.rc-flow-node[data-role="分析员"] .rc-flow-node-icon {
background: #7c3aed;
}
.rc-flow-node[data-role="撰写员"] .rc-flow-node-icon {
background: #ea580c;
}
.rc-flow-node[data-role="审稿员"] .rc-flow-node-icon {
background: #16a34a;
}
.rc-flow-node-title {
font-weight: 600;
line-height: 1.3;
flex: 1;
min-width: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: var(--rc-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-flow-node-sub {
.rc-flow-node-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 8px;
width: 100%;
padding: 0 12px 12px;
align-items: center;
}
.rc-flow-node-field-label {
font-size: 12px;
font-weight: 500;
line-height: 16px;
color: var(--rc-text-secondary);
font-size: 11px;
line-height: 1.3;
text-align: right;
white-space: nowrap;
}
.rc-flow-node-field-value {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
font-size: 12px;
color: var(--rc-text-primary);
}
.rc-flow-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
height: 20px;
padding: 0 7px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-flow-pill-input {
background: var(--rc-pill-input-bg);
color: var(--rc-pill-input-fg);
}
.rc-flow-pill-output {
background: var(--rc-pill-output-bg);
color: var(--rc-pill-output-fg);
}
.rc-flow-pill-neutral {
background: var(--rc-pill-neutral-bg);
color: var(--rc-pill-neutral-fg);
}
.rc-flow-node-status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-size: 12px;
font-weight: 500;
}
/* 活动连线低强度流动 */
.rc-edge-active {
.rc-flow-handle {
width: 8px !important;
height: 8px !important;
background: var(--rc-flow-port-idle) !important;
border: 2px solid var(--rc-bg-panel) !important;
border-radius: 50% !important;
}
.rc-flow-node:hover .rc-flow-handle {
background: var(--rc-flow-port) !important;
}
/* 活动连线低强度流动(对齐 Coze processing-line) */
.rc-edge .react-flow__edge-path {
stroke: var(--rc-flow-edge);
}
.rc-edge-active .react-flow__edge-path {
stroke: var(--rc-running);
stroke-dasharray: 6 4;
animation: rc-edge-flow 1.2s linear infinite;
stroke-dasharray: 5;
animation: rc-edge-flow 0.5s linear infinite;
}
@keyframes rc-edge-flow {
to {
stroke-dashoffset: -10;
from {
stroke-dashoffset: 10;
}
}
/* ── 节点说明抽屉(覆盖画布,不占第三栏)─────────────────────────────── */
.rc-drawer-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.2);
z-index: 20;
@keyframes rc-node-running-pulse {
0%,
100% {
box-shadow:
0 0 0 4px rgba(37, 99, 235, 0.1),
0 4px 12px rgba(37, 99, 235, 0.16);
}
50% {
box-shadow:
0 0 0 8px rgba(37, 99, 235, 0.04),
0 6px 18px rgba(37, 99, 235, 0.24);
}
}
/* ── 节点说明抽屉(Coze 右侧 NodeForm 贴边白面板)─────────────────────── */
.rc-drawer {
position: absolute;
top: 8px;
right: 8px;
bottom: 8px;
width: min(420px, calc(100% - 16px));
width: min(440px, calc(100% - 16px));
display: flex;
flex-direction: column;
border-radius: 10px;
border-radius: 8px;
border: 1px solid var(--rc-border);
background: var(--rc-bg-panel);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.08);
z-index: 21;
overflow: hidden;
animation: rc-drawer-in 0.18s ease-out;
@@ -430,8 +774,62 @@
}
}
.rc-drawer-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 12px 10px;
border-bottom: 1px solid var(--rc-border);
background: linear-gradient(180deg, rgb(var(--rc-flow-accent-rgb) / 8%) 0%, var(--rc-bg-panel) 100%);
flex-shrink: 0;
}
.rc-drawer-title {
font-size: 16px;
font-weight: 500;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rc-drawer-subtitle {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
font-size: 12px;
line-height: 16px;
color: var(--rc-text-secondary);
}
.rc-drawer-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--rc-text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.rc-drawer-icon-btn:hover {
background: var(--rc-bg-secondary);
color: var(--rc-text-primary);
}
.rc-drawer-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.rc-drawer-section {
padding: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--rc-border);
}
@@ -439,11 +837,68 @@
border-bottom: none;
}
.rc-drawer-label {
font-size: 11px;
font-weight: 600;
.rc-drawer-section-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 0;
border: none;
background: transparent;
font-size: 14px;
font-weight: 500;
line-height: 22px;
color: var(--rc-text-primary);
cursor: pointer;
text-align: left;
}
.rc-drawer-chevron {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--rc-text-secondary);
margin-bottom: 4px;
transform: rotate(-90deg);
transition: transform 0.16s ease;
}
.rc-drawer-section[data-open="true"] .rc-drawer-chevron {
transform: rotate(0deg);
}
.rc-drawer-section-body {
margin-top: 8px;
}
.rc-drawer-text {
font-size: 12px;
line-height: 20px;
color: var(--rc-text-primary);
}
.rc-drawer-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
font-size: 12px;
line-height: 20px;
}
.rc-drawer-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.rc-drawer-footer {
padding: 12px 16px;
border-top: 1px solid var(--rc-border);
flex-shrink: 0;
background: var(--rc-bg-panel);
}
/* ── Composer ─────────────────────────────────────────────────────────── */
@@ -593,7 +1048,11 @@
/* ── 无障碍:减少动效 ─────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.rc-edge-active {
.rc-edge-active .react-flow__edge-path {
animation: none;
}
.rc-flow-node[data-status="running"],
.rc-flow-node[data-status="validating"] {
animation: none;
}
.rc-drawer {
@@ -602,11 +1061,25 @@
.rc-spin {
animation-duration: 2.4s;
}
.rc-agent-avatar-ping {
animation: none;
}
.rc-resizer::after,
.rc-btn,
.rc-plan-card,
.rc-agent-tab,
.rc-agent-avatar,
.rc-flow-node {
transition: none;
}
}
.dark .rc-root {
--rc-bg-canvas: #1c2128;
--rc-flow-edge: #7b82ee;
--rc-flow-dot: rgba(255, 255, 255, 0.22);
--rc-flow-minimap-mask: rgba(28, 33, 40, 0.72);
--rc-pill-input-bg: rgba(194, 65, 12, 0.22);
--rc-pill-input-fg: #fdba74;
--rc-pill-output-bg: rgba(77, 83, 232, 0.28);
--rc-pill-output-fg: #c7c9ff;
}
@@ -8,8 +8,9 @@ import {
buildBaseStreamFacade,
toLangGraphMessages,
} from "../state/message-adapter";
import { selectMessagesForAgentRun } from "../state/selectors";
import type { CollaborationMessage } from "../api/types";
import { selectMessagesForAgentRun, selectAgentTabs, selectParallelMembers, selectConversationMessages, stitchParallelAgentBlocks } from "../state/selectors";
import { createInitialCollaborationState } from "../state/event-reducer";
import type { AgentRun, CollaborationMessage } from "../api/types";
function human(id: string, content: string): CollaborationMessage {
return { message_id: id, role: "human", content, visibility: "visible", created_at: "t", completed_at: "t" };
@@ -57,7 +58,7 @@ test("一对一映射:human/ai/tool 保持原文与归属标记", () => {
assert.equal(kwargs?.agentRunId, "a_brand");
});
test("并行聚焦:共享 human + 无归属 + 目标成员,其余成员被过滤", () => {
test("并行聚焦:只保留目标成员自己的消息,不混入 human / 协调者 / 其它成员", () => {
const messages = [
human("h1", "开始"),
ai("c1", null, "我先分派任务"),
@@ -67,12 +68,120 @@ test("并行聚焦:共享 human + 无归属 + 目标成员,其余成员被
const focusedBrand = selectMessagesForAgentRun(messages, "a_brand");
assert.deepEqual(
focusedBrand.map((message) => message.message_id),
["h1", "c1", "b1"],
["b1"],
);
// null = 全量视图(单成员 / 协调者阶段)
assert.equal(selectMessagesForAgentRun(messages, null).length, 4);
});
function agentRun(partial: Partial<AgentRun> & Pick<AgentRun, "agent_run_id" | "display_name">): AgentRun {
return {
role: "研究员",
status: "running",
phase_id: "phase_parallel_research",
started_at: "2026-09-03T00:00:00Z",
...partial,
};
}
test("并行头像条:同一 phase 且至少两人仍在执行时才出现,不含协调者", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
coord: agentRun({
agent_run_id: "coord",
display_name: "协调者",
role: "协调者",
phase_id: "phase_coordinator",
status: "running",
}),
market: agentRun({ agent_run_id: "a_market", display_name: "市场分析师", started_at: "t1" }),
brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师", started_at: "t2" }),
};
const tabs = selectAgentTabs(state);
assert.deepEqual(
tabs.map((tab) => tab.agent_run_id),
["a_market", "a_brand"],
);
assert.equal(selectParallelMembers(state).length, 2);
});
test("并行波次全部结束后头像条消失,消息列表恢复全量", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
market: agentRun({
agent_run_id: "a_market",
display_name: "市场分析师",
status: "completed",
}),
brand: agentRun({
agent_run_id: "a_brand",
display_name: "品牌竞争分析师",
status: "completed",
}),
};
assert.deepEqual(selectAgentTabs(state), []);
});
test("画布节点完成后即使 agentRun 仍为 running,也收起并行头像条", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
market: agentRun({
agent_run_id: "a_market",
display_name: "市场分析师",
node_run_id: "n-market-attempt1",
}),
brand: agentRun({
agent_run_id: "a_brand",
display_name: "品牌竞争分析师",
node_run_id: "n-brand-attempt1",
}),
};
state.nodes = {
"n-market": {
node_id: "n-market",
label: "市场",
status: "completed",
attempt: 1,
attempts: [],
},
"n-brand": {
node_id: "n-brand",
label: "品牌",
status: "completed",
attempt: 1,
attempts: [],
},
};
assert.deepEqual(selectAgentTabs(state), []);
});
test("并行结束后各成员消息收成连续聊天记录,不交叉穿插", () => {
const state = createInitialCollaborationState("s1");
state.agentRuns = {
market: agentRun({ agent_run_id: "a_market", display_name: "市场分析师", started_at: "t1", status: "completed" }),
brand: agentRun({ agent_run_id: "a_brand", display_name: "品牌竞争分析师", started_at: "t2", status: "completed" }),
};
const messages = [
human("h1", "开始"),
ai("c1", null, "我先分派任务"),
ai("m1", "a_market", "市场检索中"),
ai("b1", "a_brand", "品牌检索中"),
ai("m2", "a_market", "市场规模结论"),
ai("b2", "a_brand", "品牌竞争结论"),
ai("c2", null, "开始核验"),
];
assert.deepEqual(
stitchParallelAgentBlocks(messages, state).map((message) => message.message_id),
["h1", "c1", "m1", "m2", "b1", "b2", "c2"],
);
state.messageOrder = messages.map((message) => message.message_id);
state.messagesById = Object.fromEntries(messages.map((message) => [message.message_id, message]));
assert.deepEqual(
selectConversationMessages(state, null).map((message) => message.message_id),
["h1", "c1", "m1", "m2", "b1", "b2", "c2"],
);
});
test("业务卡虚拟消息经 groupMessages 识别为 assistant:business-card(集中式扩展)", () => {
const card = buildCommandConfirmCard({
command_id: "cmd1",
@@ -1175,7 +1175,8 @@ export default function BusinessChainEditorPage() {
{/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2">
{/* 发布:未发布 → 弹窗选「确认审核 / 直接发布」;审核中 → 撤回;已发布 → 收回。 */}
{/* 发布:未发布 → 弹窗(管理员可选「直接发布 / 确认审核」,普通用户提交审核);
审核中 → 撤回;已发布 → 收回。 */}
{currentChainId && (currentChainMine || isAdmin) && (
<button
type="button"
@@ -1186,7 +1187,9 @@ export default function BusinessChainEditorPage() {
? "已发布为公共,点击收回为私有"
: currentChainPending
? "审核中,点击撤回审核"
: "发布为公共:可选提交审核(管理员发布)或直接发布"
: isAdmin
? "发布为公共:可选提交审核(管理员发布)或直接发布"
: "提交审核:由管理员审核后发布为公共"
}
className={cn(
"flex items-center gap-1.5 border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60",
@@ -1438,7 +1441,8 @@ export default function BusinessChainEditorPage() {
}}
/>
{/* 发布审核确认弹窗:确认审核 → 进入「待审核」由管理员发布;直接发布 → 立即公共可见。 */}
{/* 发布审核确认弹窗:确认审核 → 进入「待审核」由管理员发布;管理员可选
「直接发布」立即公共可见(普通用户无此权限,只能走审核)。 */}
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -1447,19 +1451,22 @@ export default function BusinessChainEditorPage() {
发布业务链条
</DialogTitle>
<DialogDescription>
是否需要审核?确认审核后该链条将进入「待审核」,由管理员在业务链条列表中发布;
直接发布则立即对所有账号可见 / 选用。
{isAdmin
? "是否需要审核?确认审核后该链条将进入「待审核」,由管理员在业务链条列表中发布;直接发布则立即对所有账号可见 / 选用。"
: "提交后该链条将进入「待审核」,由管理员在业务链条列表中审核发布;发布前可随时撤回审核。"}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setReviewDialogOpen(false)}>
取消
</Button>
<Button variant="secondary" onClick={handleDirectPublish} disabled={publishing}>
直接发布
</Button>
{isAdmin && (
<Button variant="secondary" onClick={handleDirectPublish} disabled={publishing}>
直接发布
</Button>
)}
<Button onClick={handleSubmitReview} disabled={publishing}>
确认审核
{isAdmin ? "确认审核" : "提交审核"}
</Button>
</DialogFooter>
</DialogContent>
@@ -65,7 +65,14 @@ import { useRouteNavigate } from "@/core/route-base";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { approveChainReview, deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
import {
approveChainReview,
deleteChain,
listChains,
setChainPublic,
submitChainForReview,
type ChainScope,
} from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
@@ -163,26 +170,70 @@ export default function BusinessChainListPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 操作:发布 / 取消发布 ────────────────────────────────────────────────
// 「待审核」筛选里 = 管理员审核发布(is_public=true 并清除待审核标记);
// 其它筛选里 = 常规发布 / 取消发布。
// ── 操作:发布 / 取消发布 / 提交审核 / 审核发布 ──────────────────────────
// 发布为公共(is_public=true)仅管理员可执行:
// - 「待审核」筛选里 = 管理员审核发布(is_public=true 并清除待审核标记);
// - 管理员在其它筛选里点待审核链条的发布 = 同样走审核发布;
// - 管理员对普通链条 = 常规发布 / 取消发布。
// 普通用户:未发布链条的发布入口 = 提交审核(等待管理员发布,不能自行发布);
// 已发布链条可取消发布(收回为私有)。
const handleTogglePublic = useCallback(
async (chain: BusinessChain) => {
// 审核发布:待审核筛选(仅管理员可达)或管理员点待审核链条。
if (scope === "pending" || (isAdmin && chain.pendingReview)) {
setPublishingId(chain.id);
try {
const updated = await approveChainReview(chain.id);
if (updated) {
await refresh(scope);
await loadStats();
toast.success(`「${chain.title || "未命名链条"}」审核通过并已发布到公共选用库`);
} else {
toast.error("链条已不存在,可能被删除");
await refresh(scope);
}
} catch (err) {
toast.error(`操作失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setPublishingId(null);
}
return;
}
// 普通用户的未发布链条:提交审核,不能直接发布。
if (!isAdmin && !chain.isPublic) {
if (chain.pendingReview) {
toast.info("该链条审核中,请等待管理员发布");
return;
}
setPublishingId(chain.id);
try {
const updated = await submitChainForReview(chain.id);
if (updated) {
await refresh(scope);
await loadStats();
toast.success(`「${chain.title || "未命名链条"}」已提交审核,等待管理员发布`);
} else {
toast.error("链条已不存在,可能被删除");
await refresh(scope);
}
} catch (err) {
toast.error(`提交审核失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setPublishingId(null);
}
return;
}
// 其余(管理员发布/取消发布,普通用户取消发布)走常规开关。
setPublishingId(chain.id);
try {
const updated =
scope === "pending"
? await approveChainReview(chain.id)
: await setChainPublic(chain.id, !chain.isPublic);
const updated = await setChainPublic(chain.id, !chain.isPublic);
if (updated) {
await refresh(scope);
await loadStats();
toast.success(
scope === "pending"
? `「${chain.title || "未命名链条"}」审核通过并已发布到公共选用库`
: chain.isPublic
? `已取消发布「${chain.title || "未命名链条"}」`
: `「${chain.title || "未命名链条"}」已发布到公共选用库`,
chain.isPublic
? `已取消发布「${chain.title || "未命名链条"}」`
: `「${chain.title || "未命名链条"}」已发布到公共选用库`,
);
} else {
toast.error("链条已不存在,可能被删除");
@@ -194,7 +245,7 @@ export default function BusinessChainListPage() {
setPublishingId(null);
}
},
[refresh, scope, loadStats],
[refresh, scope, loadStats, isAdmin],
);
// ── 操作:删除(确认弹窗)─────────────────────────────────────────────────
@@ -463,6 +514,7 @@ export default function BusinessChainListPage() {
key={chain.id}
chain={chain}
canManage={isAdmin || chain.isMine !== false}
isAdmin={isAdmin}
publishing={publishingId === chain.id}
onSettings={() => goConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)}
@@ -652,6 +704,7 @@ function EmptyState({
function ChainCard({
chain,
canManage,
isAdmin,
publishing,
onSettings,
onTogglePublic,
@@ -660,6 +713,8 @@ function ChainCard({
chain: BusinessChain;
/** 能否编辑 / 发布 / 删除(本人链条 或 管理员)。 */
canManage: boolean;
/** 当前登录用户是否管理员:发布语义不同(管理员=直接发布/审核发布,普通=提交审核)。 */
isAdmin: boolean;
publishing: boolean;
onSettings: () => void;
onTogglePublic: () => void;
@@ -722,14 +777,26 @@ function ChainCard({
</div>
</div>
{/* 操作按钮:本人链条或管理员可发布 / 设置 / 删除(普通用户看别人的链条只读浏览)。 */}
{/* 操作按钮:本人链条或管理员可发布 / 设置 / 删除(普通用户看别人的链条只读浏览)。
发布图标语义:管理员=发布/审核发布/取消发布;普通用户=提交审核(未发布)/
取消发布(已发布)/ 审核中(点击仅提示)。 */}
{canManage && (
<div className="flex shrink-0 items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={onTogglePublic}
disabled={publishing}
title={chain.isPublic ? "取消发布" : "发布到公共选用库"}
title={
chain.isPublic
? "取消发布"
: isAdmin
? chain.pendingReview
? "审核通过并发布"
: "发布到公共选用库"
: chain.pendingReview
? "审核中,等待管理员发布"
: "提交审核(由管理员审核发布)"
}
className={cn(
"rounded-lg p-1.5 transition disabled:opacity-50",
chain.isPublic
@@ -1012,7 +1079,13 @@ function ChainTable({
type="button"
onClick={() => onTogglePublic(chain)}
disabled={publishingId === chain.id}
title={chain.isPublic ? "取消发布" : "发布到公共选用库"}
title={
chain.isPublic
? "取消发布"
: chain.pendingReview
? "审核通过并发布"
: "发布到公共选用库"
}
className="rounded-lg p-1.5 text-emerald-600 transition hover:bg-emerald-50 disabled:opacity-50 dark:text-[#52C41A] dark:hover:bg-[#52C41A]/15"
>
{publishingId === chain.id ? (
@@ -74,6 +74,7 @@ import {
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
@@ -3678,33 +3679,33 @@ function RoundtableEntryRail({
SEAT_CHIP_PALETTE.length
];
return (
<div
key={`${si}-${sj}-${seat.agent_id}`}
className="group/seat relative"
>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border",
palette,
)}
>
<Cpu className="h-3 w-3" />
</span>
{/* 悬停浮层显示名称 / 描述 */}
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 group-hover/seat:block">
<div className="max-w-[180px] rounded-lg bg-neutral-900 px-2 py-1 text-white shadow-lg">
<div className="whitespace-nowrap text-[10px] font-semibold">
{seat.name}
</div>
{seat.description && (
<div className="mt-0.5 whitespace-normal text-[9px] leading-snug text-neutral-300">
{seat.description}
</div>
<Tooltip key={`${si}-${sj}-${seat.agent_id}`}>
<TooltipTrigger asChild>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border",
palette,
)}
>
<Cpu className="h-3 w-3" />
</span>
</TooltipTrigger>
{/* 悬停浮层显示名称 / 描述。Radix Portal 渲染到 body,
不被折叠层的 overflow-hidden / 列表滚动容器裁剪。 */}
<TooltipContent
side="top"
className="max-w-[280px] rounded-lg bg-neutral-900 px-3 py-2 text-white shadow-lg"
>
<div className="whitespace-nowrap text-[16px] font-semibold">
{seat.name}
</div>
<div className="absolute left-1/2 top-full h-0 w-0 -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-neutral-900" />
</div>
</div>
{seat.description && (
<div className="mt-1 text-[16px] leading-snug text-neutral-300">
{seat.description}
</div>
)}
</TooltipContent>
</Tooltip>
);
})}
</div>