Files
deerflow-code/web-code/components/angentchat/SixStepConversation.tsx
T
2026-05-27 19:43:15 +08:00

661 lines
24 KiB
TypeScript

import React, { useEffect, useMemo, useRef } from 'react';
import { Bot, CheckCircle2, Database, FileCheck2, Sparkles, UserRound } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTheme } from '../../contexts/ThemeContext';
import ExpertMatchCards from './ExpertMatchCards';
import StreamErrorNotice from './StreamErrorNotice';
import {
ActorCardItem,
BehaviorCardItem,
InlineCollapsible,
NarrativeCardItem,
ParentGroupHeader,
PurposeCardItem,
STAGE_ICONS,
StageHeader,
ThinkingBlock,
} from './SixStepCards';
import type {
ActorCard,
BehaviorDriverCard,
InfoStageData,
NarrativeCard,
PurposeCard,
SixStepMessage,
} from './types';
export interface ExpertMatchHandlers {
onSelectExpert: (messageId: string, expertId: string) => void;
onRefetchExpert: (messageId: string) => void;
onCreateExpert: (messageId: string) => void;
onConfirmExpert: (messageId: string) => void;
}
interface SixStepConversationProps {
messages: SixStepMessage[];
isGenerating: boolean;
activeMessageId?: string | null;
expertHandlers?: ExpertMatchHandlers;
}
interface AvatarProps {
role: 'user' | 'assistant';
active?: boolean;
}
const Avatar: React.FC<AvatarProps> = ({ role, active }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div className={`relative w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 shadow-sm overflow-hidden ${
role === 'assistant'
? isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white'
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
} ${active ? 'sixstep-pulse' : ''}`}>
<Icon className="w-5 h-5" />
{role === 'assistant' && active && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200" />
)}
</div>
);
};
const InfoStage: React.FC<{ data: InfoStageData; running: boolean }> = ({ data, running }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<InlineCollapsible
defaultOpen={false}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
isDark
? `bg-blue-500/15 text-blue-200 ${running ? 'sixstep-pulse' : ''}`
: `bg-blue-50 text-blue-500 ${running ? 'sixstep-pulse' : ''}`
}`}>
<Database className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<span>信息准备</span>
{data.complete && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
}`}>已完成</span>
)}
{running && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-50 text-blue-600'
}`}>收集中</span>
)}
</div>
<div className={`text-xs mt-0.5 ${
running
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-400' : 'text-gray-500'
}`}>
{running ? '正在收集热搜与上下文信息...' : '已整合信息准备素材'}
</div>
</div>
</div>
}
>
{data.content ? (
<div className={`prose prose-sm max-w-none text-sm leading-relaxed ${
isDark ? 'prose-invert text-slate-300' : 'text-gray-700'
}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.content}</ReactMarkdown>
</div>
) : (
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{running ? '正在获取信息...' : '无内容'}
</div>
)}
</InlineCollapsible>
);
};
const MarkdownMessage: React.FC<{ content: string; isDark: boolean }> = ({ content, isDark }) => (
<div className={`rounded-2xl border px-5 py-4 ${
isDark
? 'border-slate-700/50 bg-slate-800/30'
: 'border-gray-100 bg-white'
}`}>
<div className={`prose prose-sm max-w-none leading-relaxed ${isDark ? 'prose-invert' : ''}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
);
const PurposeStage: React.FC<{ data: NonNullable<SixStepMessage['stages']>['purpose']; running: boolean }> = ({ data, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.purpose;
const cards = data.cards ?? [];
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="目的生成"
desc={running ? '正在推演核心目的...' : '已识别核心目的方向'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="purpose"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">目的卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{cards.length === 0 ? (
<div className="text-xs opacity-60">等待生成目的卡片...</div>
) : (
<div className="space-y-2">
{cards.map((card) => (
<PurposeCardItem key={card.id} card={card} />
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const ActorStage: React.FC<{
data: NonNullable<SixStepMessage['stages']>['actor'];
purposes: PurposeCard[];
running: boolean;
}> = ({ data, purposes, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.actor;
const cards = data.cards ?? [];
const groups = useMemo(() => {
const map = new Map<string, ActorCard[]>();
cards.forEach((card) => {
const list = map.get(card.purposeId) ?? [];
list.push(card);
map.set(card.purposeId, list);
});
return purposes
.map((purpose) => ({ purpose, items: map.get(purpose.id) ?? [] }))
.filter((g) => g.items.length > 0);
}, [cards, purposes]);
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="行为体生成"
desc={running ? '正在匹配行为体角色...' : '已生成行为体清单'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="actor"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">行为体卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{groups.length === 0 ? (
<div className="text-xs opacity-60">等待生成行为体卡片...</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.purpose.id} className="space-y-2 sixstep-fade-up">
<ParentGroupHeader label="所属目的" parentTitle={group.purpose.title} count={group.items.length} variant="actor" />
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
{group.items.map((card) => (
<ActorCardItem key={card.id} card={card} />
))}
</div>
</div>
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const BehaviorStage: React.FC<{
data: NonNullable<SixStepMessage['stages']>['behavior'];
actors: ActorCard[];
running: boolean;
}> = ({ data, actors, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.behavior;
const cards = data.cards ?? [];
const groups = useMemo(() => {
const map = new Map<string, BehaviorDriverCard[]>();
cards.forEach((card) => {
const list = map.get(card.actorId) ?? [];
list.push(card);
map.set(card.actorId, list);
});
return actors
.map((actor) => ({ actor, items: map.get(actor.id) ?? [] }))
.filter((g) => g.items.length > 0);
}, [cards, actors]);
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="预期行为与驱动因素生成"
desc={running ? '正在推演行为及驱动因素...' : '已生成预期行为与驱动因素'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="behavior"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">预期行为与驱动因素卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{groups.length === 0 ? (
<div className="text-xs opacity-60">等待生成预期行为与驱动因素卡片...</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.actor.id} className="space-y-2 sixstep-fade-up">
<ParentGroupHeader label="所属行为体" parentTitle={group.actor.title} count={group.items.length} variant="behavior" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 pl-2 border-l-2 border-dashed border-slate-400/20">
{group.items.map((card) => (
<BehaviorCardItem key={card.id} card={card} />
))}
</div>
</div>
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const NarrativeStage: React.FC<{
data: NonNullable<SixStepMessage['stages']>['narrative'];
behaviors: BehaviorDriverCard[];
running: boolean;
}> = ({ data, behaviors, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.narrative;
const cards = data.cards ?? [];
const groups = useMemo(() => {
const map = new Map<string, NarrativeCard[]>();
cards.forEach((card) => {
const list = map.get(card.driverId) ?? [];
list.push(card);
map.set(card.driverId, list);
});
return behaviors
.map((behavior) => ({ behavior, items: map.get(behavior.id) ?? [] }))
.filter((g) => g.items.length > 0);
}, [cards, behaviors]);
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="叙事生成"
desc={running ? '正在构建叙事框架...' : '已生成叙事卡片'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="narrative"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">叙事卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{groups.length === 0 ? (
<div className="text-xs opacity-60">等待生成叙事卡片...</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.behavior.id} className="space-y-2 sixstep-fade-up">
<ParentGroupHeader
label="所属驱动因素"
parentTitle={group.behavior.driver}
count={group.items.length}
variant="narrative"
/>
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
{group.items.map((card) => (
<NarrativeCardItem key={card.id} card={card} />
))}
</div>
</div>
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const FinalAnswer: React.FC<{ text: string; running: boolean }> = ({ text, running }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (!text && !running) return null;
return (
<div className={`rounded-2xl border p-4 ${
isDark
? 'border-slate-600/40 bg-slate-700/40 shadow-lg shadow-black/10'
: 'border-red-100 bg-white shadow-sm'
}`}>
<div className="flex items-center gap-2 mb-2">
<FileCheck2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
<span className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>最终结果</span>
{running && (
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
正在汇总输出
</span>
)}
</div>
<pre className={`whitespace-pre-wrap text-sm leading-relaxed font-sans ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{text || '准备汇总输出...'}
</pre>
</div>
);
};
const SelectedSummaryPill: React.FC<{ message: SixStepMessage }> = ({ message }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (!message.selectedExpert) return null;
const mcps = message.selectedMcps ?? [];
const labelMap: Record<string, string> = {
purpose: '目的',
actor: '行为体',
behavior_factor: '预期行为和驱动因素',
narrative: '叙事',
};
return (
<div className={`flex flex-wrap items-center gap-2 rounded-2xl border px-3 py-2 ${
isDark ? 'border-cyan-400/30 bg-cyan-500/10 text-cyan-100' : 'border-red-200 bg-red-50/70 text-red-700'
}`}>
<CheckCircle2 className="w-4 h-4" />
<span className="text-xs font-semibold">已选专家</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
isDark ? 'bg-slate-900/40 text-cyan-100' : 'bg-white text-red-600 shadow-sm'
}`}>
{message.selectedExpert.name}
</span>
{mcps.length > 0 && (
<>
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-500'}`}>·</span>
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
执行步骤:{mcps.map((k) => labelMap[k] || k).join(' → ')}
</span>
</>
)}
</div>
);
};
interface AssistantMessageProps {
message: SixStepMessage;
active: boolean;
expertHandlers?: ExpertMatchHandlers;
}
const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, active, expertHandlers }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isMarkdown = message.outputFormat === 'markdown';
const isFollowUp = message.phase === 'follow-up';
const stages = message.stages ?? {};
const purposes = stages.purpose?.cards ?? [];
const actors = stages.actor?.cards ?? [];
const behaviors = stages.behavior?.cards ?? [];
const isInfoRunning = message.mainStepStatus?.info === 'running';
const isPurposeRunning = message.subStepStatus?.purpose === 'running';
const isActorRunning = message.subStepStatus?.actor === 'running';
const isBehaviorRunning = message.subStepStatus?.behavior === 'running';
const isNarrativeRunning = message.subStepStatus?.narrative === 'running';
const isFinalRunning = message.mainStepStatus?.final === 'running';
const generating =
isInfoRunning ||
message.mainStepStatus?.sixstep === 'running' ||
isFinalRunning;
// follow-up 在等待第一个 delta 时显示加载态
const followUpGenerating = isFollowUp && active && !message.markdownContent;
const noStageYet = !stages.info && !stages.purpose && !stages.actor && !stages.behavior && !stages.narrative;
const expertMatch = message.expertMatch;
const inExpertPhase = message.phase === 'expert-match' && !!expertMatch;
const expertConfirmed = expertMatch?.status === 'confirmed' || !!message.selectedExpert;
const expertActive = active && inExpertPhase && !expertConfirmed;
// Only render info/stages/finalAnswer when we're in an analysis phase.
// Expert-match, clarifying, and follow-up phases show their own dedicated UI instead.
const showAnalysisContent =
!inExpertPhase &&
message.phase !== 'clarifying' &&
message.phase !== 'follow-up';
return (
<div className="flex gap-3">
<Avatar role="assistant" active={generating || expertActive || followUpGenerating} />
<div className={`flex-1 min-w-0 max-w-full rounded-2xl px-4 py-3 border ${
isDark
? 'bg-slate-800/40 border-slate-700/50'
: 'bg-transparent border-transparent shadow-none px-0 py-0'
}`}>
<div className="space-y-3">
{/* Expert match phase: show expert cards before stages */}
{inExpertPhase && expertMatch && (
<ExpertMatchCards
data={expertMatch}
locked={expertConfirmed || !active}
onSelectExpert={(id) => expertHandlers?.onSelectExpert(message.id, id)}
onRefetch={() => expertHandlers?.onRefetchExpert(message.id)}
onCreateExpert={() => expertHandlers?.onCreateExpert(message.id)}
onConfirm={() => expertHandlers?.onConfirmExpert(message.id)}
/>
)}
{inExpertPhase && (message.errors?.length ?? 0) > 0 && (
<StreamErrorNotice errors={message.errors!} />
)}
{/* Selected expert / MCP summary once moved to generate phase */}
{message.selectedExpert && message.phase !== 'expert-match' && (
<SelectedSummaryPill message={message} />
)}
{showAnalysisContent && (
<>
{/* Info preparing placeholder when no stage data yet */}
{isInfoRunning && noStageYet && (
<div className={`rounded-2xl border px-4 py-3 ${
isDark ? 'border-slate-700/60 bg-slate-900/40 text-slate-300' : 'border-blue-100 bg-white text-gray-600'
}`}>
<div className="flex items-center gap-2 text-xs">
<Database className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-blue-500'}`} />
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
正在调用信息准备,收集热搜与上下文...
</span>
</div>
</div>
)}
{/* Info prepare result (collapsible) */}
{stages.info && <InfoStage data={stages.info} running={isInfoRunning} />}
{(isMarkdown || isFollowUp) ? (
/* ---- Markdown / Follow-up mode ---- */
<>
{(message.markdownContent || generating || followUpGenerating) && (
message.markdownContent ? (
<MarkdownMessage content={message.markdownContent} isDark={isDark} />
) : (
<div className={`rounded-2xl border px-4 py-3 ${
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-gray-100 bg-white'
}`}>
<div className="flex items-center gap-2 text-xs">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
{isFollowUp ? '正在回答你的问题...' : '正在生成推演内容...'}
</span>
</div>
</div>
)
)}
{(message.errors?.length ?? 0) > 0 && (
<StreamErrorNotice errors={message.errors!} />
)}
</>
) : (
/* ---- Structured mode ---- */
<>
{stages.purpose && <PurposeStage data={stages.purpose} running={isPurposeRunning} />}
{stages.actor && <ActorStage data={stages.actor} purposes={purposes} running={isActorRunning} />}
{stages.behavior && <BehaviorStage data={stages.behavior} actors={actors} running={isBehaviorRunning} />}
{stages.narrative && (
<NarrativeStage data={stages.narrative} behaviors={behaviors} running={isNarrativeRunning} />
)}
{(message.finalAnswer || isFinalRunning) && (
<FinalAnswer text={message.finalAnswer ?? ''} running={isFinalRunning} />
)}
{(message.errors?.length ?? 0) > 0 && (
<StreamErrorNotice errors={message.errors!} />
)}
</>
)}
</>
)}
</div>
</div>
</div>
);
};
const UserMessage: React.FC<{ message: SixStepMessage }> = ({ message }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3 justify-end">
<div className={`max-w-[72%] rounded-2xl px-4 py-3 shadow-sm ${
isDark ? 'bg-blue-600/70 text-white' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
}`}>
<p className="whitespace-pre-wrap leading-relaxed text-sm">{message.content}</p>
</div>
<Avatar role="user" />
</div>
);
};
const SixStepConversation: React.FC<SixStepConversationProps> = ({ messages, isGenerating, activeMessageId, expertHandlers }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages, isGenerating]);
if (messages.length === 0) {
return (
<div className={`h-full flex flex-col items-center justify-center px-6 text-center ${
isDark ? 'text-slate-400' : 'text-gray-500'
}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${
isDark ? 'bg-slate-700/40 text-cyan-300' : 'bg-gradient-to-br from-red-50 to-orange-50 text-red-500'
}`}>
<Sparkles className="w-10 h-10 opacity-80" />
</div>
<p className={`text-lg font-medium ${isDark ? 'text-white' : 'text-gray-800'}`}>开始 6 步法推演</p>
<p className="text-sm mt-2 max-w-md">
输入问题或场景描述,模型会按照"信息准备 → 6 步法推演 → 最终结果"的流程逐步生成结构化输出。
</p>
</div>
);
}
return (
<div ref={scrollRef} className="h-full overflow-y-auto px-5 py-5 space-y-5">
{messages.map((msg) => (
<div key={msg.id}>
{msg.role === 'user' ? (
<UserMessage message={msg} />
) : (
<AssistantMessage
message={msg}
active={msg.id === activeMessageId}
expertHandlers={expertHandlers}
/>
)}
</div>
))}
</div>
);
};
export default SixStepConversation;