661 lines
24 KiB
TypeScript
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;
|