feat: 重构多轮RAG展示并完善报告协作、业务链审核与 agentfx 种子
抽出专用多轮RAG消息列表并升级会话模型;强化报告协作画布与检查器;业务链普通用户改为提交审核;扩展 agentfx 报告技能种子,并将含 admin 的用户名自动提权为管理员。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user