代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-27 19:43:15 +08:00
1 parent 8f5af46ec2
commit 8f75e12871
168 files changed
+76575

No files matched your search

@@ -0,0 +1,594 @@
import { useEffect, useMemo, useState } from 'react';
import { Bot, Edit, Eye, Plus, Search, Sparkles, Trash2, X } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
interface AgentItem {
key: string;
type: 'builtin' | 'custom' | string;
deletable: boolean;
name: string;
description: string;
default_prompt: string;
schema_hint: string;
bound_skills: string[];
order: number;
}
interface SkillItem {
key: string;
name: string;
description?: string;
}
interface AgentCardListProps {
onEdit?: (key: string) => void;
}
type TypeFilter = 'all' | 'builtin' | 'custom';
interface CreateAgentForm {
name: string;
description: string;
bound_skills: string[];
}
const INITIAL_FORM: CreateAgentForm = {
name: '',
description: '',
bound_skills: [],
};
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
export default function AgentCardList({ onEdit }: AgentCardListProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [agents, setAgents] = useState<AgentItem[]>([]);
const [skills, setSkills] = useState<SkillItem[]>([]);
const [keyword, setKeyword] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState<CreateAgentForm>(INITIAL_FORM);
const [skillKeyword, setSkillKeyword] = useState('');
const showNotice = (message: string) => {
setNotice(message);
window.setTimeout(() => {
setNotice((current) => (current === message ? null : current));
}, 2400);
};
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [agentRes, skillRes] = await Promise.all([fetch(`${backUrl}/api/agents`), fetch(`${backUrl}/api/skills`)]);
if (!agentRes.ok) {
throw new Error(`加载 MCP 失败:${agentRes.status}`);
}
const agentData = (await agentRes.json()) as AgentItem[];
const skillData = skillRes.ok ? ((await skillRes.json()) as SkillItem[]) : [];
setAgents(agentData.sort((a, b) => a.order - b.order));
setSkills(skillData);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadData();
}, []);
const filteredAgents = useMemo(() => {
const kw = keyword.trim().toLowerCase();
return agents.filter((agent) => {
const matchesKeyword =
!kw ||
agent.name.toLowerCase().includes(kw) ||
agent.key.toLowerCase().includes(kw) ||
agent.description.toLowerCase().includes(kw);
const matchesType = typeFilter === 'all' || agent.type === typeFilter;
return matchesKeyword && matchesType;
});
}, [agents, keyword, typeFilter]);
const filteredSkills = useMemo(() => {
const kw = skillKeyword.trim().toLowerCase();
if (!kw) {
return skills;
}
return skills.filter((skill) =>
`${skill.name} ${skill.key} ${skill.description || ''}`.toLowerCase().includes(kw),
);
}, [skillKeyword, skills]);
const resetCreateForm = () => {
setCreateForm(INITIAL_FORM);
setSkillKeyword('');
};
const openCreateModal = () => {
resetCreateForm();
setCreateOpen(true);
};
const closeCreateModal = () => {
setCreateOpen(false);
resetCreateForm();
};
const toggleSkill = (skillKey: string) => {
setCreateForm((prev) => ({
...prev,
bound_skills: prev.bound_skills.includes(skillKey)
? prev.bound_skills.filter((item) => item !== skillKey)
: [...prev.bound_skills, skillKey],
}));
};
const handleCreate = async () => {
const name = createForm.name.trim();
const description = createForm.description.trim();
if (!name) {
setError('请先输入 MCP 名称');
return;
}
setCreating(true);
setError(null);
try {
const res = await fetch(`${backUrl}/api/agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description,
bound_skills: createForm.bound_skills,
}),
});
if (!res.ok) {
let detail = `新增失败:${res.status}`;
try {
const body = (await res.json()) as { detail?: string };
if (body.detail) {
detail = body.detail;
}
} catch {
// ignore json parse errors
}
throw new Error(detail);
}
await res.json();
await loadData();
closeCreateModal();
showNotice('MCP 已新增');
} catch (e) {
setError(e instanceof Error ? e.message : '新增失败');
} finally {
setCreating(false);
}
};
const handleDelete = async (agent: AgentItem) => {
if (!agent.deletable) return;
const ok = window.confirm(`确认删除 MCP “${agent.name}”吗?`);
if (!ok) return;
const res = await fetch(`${backUrl}/api/agents/${agent.key}`, { method: 'DELETE' });
if (!res.ok) {
setError(`删除失败:${res.status}`);
return;
}
await loadData();
showNotice('MCP 已删除');
};
const selectedSkillCount = createForm.bound_skills.length;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className={`mb-2 text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
MCP 智能体配置
</h1>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
管理内置与用户新增的 MCP,并支持绑定技能
</p>
</div>
<button
type="button"
onClick={openCreateModal}
className={`inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
}`}
>
<Plus className="h-4 w-4" />
新增 MCP
</button>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative max-w-xl flex-1">
<Search
className={`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${
isDark ? 'text-slate-500' : 'text-red-400'
}`}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索 MCP 名称、Key 或描述"
className={`w-full rounded-xl border py-2.5 pl-10 pr-4 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-blue-800/30 bg-slate-800/50 text-white placeholder-slate-500'
: 'border-red-200 bg-white text-gray-900 placeholder-red-300'
}`}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{[
{ key: 'all', label: '全部' },
{ key: 'builtin', label: '内置 MCP' },
{ key: 'custom', label: '用户新增' },
].map((item) => {
const active = typeFilter === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setTypeFilter(item.key as TypeFilter)}
className={`rounded-full px-3 py-1.5 text-sm transition ${
active
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-600 text-white'
: isDark
? 'bg-slate-800 text-slate-300 hover:bg-slate-700'
: 'bg-white text-gray-600 ring-1 ring-gray-200 hover:bg-gray-50'
}`}
>
{item.label}
</button>
);
})}
</div>
</div>
{loading ? <div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div> : null}
{error ? <div className="text-sm text-red-500">{error}</div> : null}
{notice ? <div className="text-sm text-emerald-500">{notice}</div> : null}
{!loading && !error ? (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filteredAgents.map((agent) => (
<div
key={agent.key}
className={`group rounded-xl border p-5 transition-all duration-300 ${
isDark
? 'border-blue-800/30 bg-slate-800/30 backdrop-blur-xl hover:border-blue-500/50'
: 'border-red-100 bg-white hover:border-red-300 hover:shadow-md'
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isDark ? 'bg-blue-500/20' : 'bg-red-100'
}`}
>
<Bot className={`h-6 w-6 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{agent.name}
</h3>
<p className={`text-xs font-mono ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{agent.key}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
agent.type === 'builtin'
? isDark
? 'bg-cyan-500/15 text-cyan-200'
: 'bg-cyan-50 text-cyan-700'
: isDark
? 'bg-amber-500/15 text-amber-200'
: 'bg-amber-50 text-amber-700'
}`}
>
{agent.type === 'builtin' ? '内置' : '自定义'}
</span>
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
isDark ? 'bg-slate-700/60 text-slate-300' : 'bg-gray-100 text-gray-600'
}`}
>
#{agent.order + 1}
</span>
</div>
</div>
<p className={`mb-4 min-h-[40px] text-sm ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
{agent.description || '暂无描述'}
</p>
<div className="mb-4">
<div className={`mb-2 flex items-center gap-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<Sparkles className="h-3 w-3" />
已绑定技能
</div>
<div className="flex min-h-[28px] flex-wrap gap-1.5">
{agent.bound_skills.length === 0 ? (
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>未绑定</span>
) : (
agent.bound_skills.map((skillKey) => {
const skill = skills.find((item) => item.key === skillKey);
return (
<span
key={skillKey}
className={`rounded-full border px-2 py-0.5 text-xs ${
isDark
? 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
}`}
>
{skill?.name || skillKey}
</span>
);
})
)}
</div>
</div>
<div
className={`flex items-center justify-end gap-2 border-t pt-3 ${
isDark ? 'border-blue-800/30' : 'border-red-100'
}`}
>
<button
type="button"
onClick={() => onEdit?.(agent.key)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark
? 'bg-slate-700/60 text-slate-200 hover:bg-slate-700'
: 'border border-red-200 bg-white text-red-600 hover:bg-red-50'
}`}
>
<Eye className="h-3.5 w-3.5" />
详情
</button>
<button
type="button"
onClick={() => onEdit?.(agent.key)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
}`}
>
<Edit className="h-3.5 w-3.5" />
编辑
</button>
{agent.deletable ? (
<button
type="button"
onClick={() => void handleDelete(agent)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark
? 'bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'
: 'bg-rose-50 text-rose-600 hover:bg-rose-100'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
删除
</button>
) : null}
</div>
</div>
))}
</div>
) : null}
{!loading && !error && filteredAgents.length === 0 ? (
<div className={`py-12 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
没有匹配的 MCP
</div>
) : null}
{createOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeCreateModal();
}
}}
>
<div
className={`max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-3xl border shadow-2xl ${
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-red-100 bg-white text-gray-900'
}`}
>
<div className="flex items-center justify-between border-b px-6 py-5 dark:border-slate-700">
<div>
<h2 className="text-lg font-semibold">新增 MCP</h2>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
输入 MCP 名称、详情并选择要绑定的技能
</p>
</div>
<button
type="button"
onClick={closeCreateModal}
className={`flex h-9 w-9 items-center justify-center rounded-xl ${
isDark ? 'hover:bg-slate-800' : 'hover:bg-gray-100'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-5 overflow-y-auto px-6 py-5">
<div>
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
MCP 名称
</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:游戏分析助手"
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div>
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
详情
</label>
<textarea
value={createForm.description}
onChange={(e) => setCreateForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="一句话说明这个 MCP 的职责"
rows={4}
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<label className={`block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
绑定技能
</label>
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
已选 {selectedSkillCount} 项
</span>
</div>
<div className="mb-3">
<input
type="text"
value={skillKeyword}
onChange={(e) => setSkillKeyword(e.target.value)}
placeholder="搜索技能名称或描述"
className={`w-full rounded-xl border px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div
className={`max-h-72 space-y-2 overflow-y-auto rounded-2xl border p-3 ${
isDark ? 'border-slate-700 bg-slate-950/60' : 'border-gray-200 bg-gray-50/70'
}`}
>
{filteredSkills.length === 0 ? (
<div className={`py-8 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
没有匹配的技能
</div>
) : (
filteredSkills.map((skill) => {
const checked = createForm.bound_skills.includes(skill.key);
return (
<button
key={skill.key}
type="button"
onClick={() => toggleSkill(skill.key)}
className={`flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-left transition ${
checked
? isDark
? 'border-emerald-500/50 bg-emerald-500/10'
: 'border-emerald-200 bg-emerald-50'
: isDark
? 'border-slate-700 bg-slate-900/50 hover:bg-slate-800/70'
: 'border-white bg-white hover:bg-gray-50'
}`}
>
<div
className={`mt-1 h-4 w-4 shrink-0 rounded border ${
checked
? 'border-emerald-500 bg-emerald-500'
: isDark
? 'border-slate-600'
: 'border-gray-300'
}`}
/>
<div className="min-w-0">
<div className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{skill.name}
<span className={`ml-2 font-mono text-[11px] ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{skill.key}
</span>
</div>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{skill.description || '暂无描述'}
</p>
</div>
</button>
);
})
)}
</div>
</div>
</div>
<div
className={`flex items-center justify-end gap-3 border-t px-6 py-4 ${
isDark ? 'border-slate-700' : 'border-gray-200'
}`}
>
<button
type="button"
onClick={closeCreateModal}
className={`rounded-xl px-4 py-2 text-sm ${
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
}`}
>
取消
</button>
<button
type="button"
onClick={() => void handleCreate()}
disabled={creating}
className={`rounded-xl px-4 py-2 text-sm font-medium text-white ${
isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500'
} disabled:cursor-not-allowed disabled:opacity-60`}
>
{creating ? '创建中...' : '确认新增'}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,327 @@
import React, { useMemo, useState } from 'react';
import { Bot, ChevronDown, CheckCircle2, Loader2, Sparkles, UserRound } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import MarkdownRenderer from '../MarkdownRenderer';
import { parseAgentResponse } from '../agent/agentResponseParser';
export interface AgentChatMsg {
id: string;
role: 'user' | 'assistant';
content: string;
}
interface AvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
}
const AgentAvatar: React.FC<AvatarProps> = ({ role, isActive = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
const gradientClass =
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';
return (
<div
className={`relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-2xl shadow-sm ${gradientClass} ${isActive ? 'qa-avatar-active' : ''}`}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`relative z-10 h-5 w-5 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="qa-avatar-spark absolute -right-0.5 -top-0.5 h-3.5 w-3.5 text-yellow-200" />
)}
</div>
);
};
interface CollapsibleProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
}
const Collapsible: React.FC<CollapsibleProps> = ({
title,
children,
defaultOpen = true,
className = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
return (
<div
className={`overflow-hidden rounded-2xl border ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
}`}
>
<div className="min-w-0 flex-1">{title}</div>
<ChevronDown
className={`h-4 w-4 shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'} ${
isDark ? 'text-slate-400' : 'text-gray-500'
}`}
/>
</button>
<div
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}
>
<div className="overflow-hidden">
<div
className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${
isDark ? 'text-slate-200' : 'text-gray-700'
}`}
>
{children}
</div>
</div>
</div>
</div>
);
};
interface AssistantAnswerProps {
steps: { title: string; content: string }[];
thinkingMarkdown: string;
answerMarkdown: string;
thinkingComplete: boolean;
streaming: boolean;
}
const AssistantAnswer: React.FC<AssistantAnswerProps> = ({
steps,
thinkingMarkdown,
answerMarkdown,
thinkingComplete,
streaming,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const showThinkingSection = Boolean(thinkingMarkdown) || (!thinkingComplete && streaming);
return (
<div className="w-full min-w-0 max-w-5xl space-y-3 overflow-hidden">
{steps.length > 0 && (
<Collapsible
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
}`}
>
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
步骤
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
展示技能执行、上下文准备和模型调用进度
</div>
</div>
</div>
}
>
<div className="space-y-3">
{steps.map((step, index) => (
<div
key={`${step.title}-${index}`}
className={`rounded-xl border px-3 py-3 ${
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-100 bg-gray-50'
}`}
>
<div className={`text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{step.title}
</div>
<div className={`mt-1 whitespace-pre-wrap text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{step.content}
</div>
</div>
))}
</div>
</Collapsible>
)}
{showThinkingSection && (
<Collapsible
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}
>
<Sparkles className="h-5 w-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
思考过程
</div>
{thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div
className={`qa-generating-marquee text-xs ${
isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'
}`}
>
正在生成思考内容...
</div>
)}
</div>
</div>
}
>
{thinkingMarkdown ? (
<MarkdownRenderer
content={thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
/>
) : (
<div className={`flex items-center gap-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="h-4 w-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</Collapsible>
)}
{answerMarkdown ? (
<div
className={`rounded-2xl border p-4 ${
isDark ? 'border-slate-600/40 bg-slate-700/60' : 'border-gray-100 bg-white'
}`}
>
<MarkdownRenderer
content={answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
/>
</div>
) : null}
</div>
);
};
interface MessageRowProps {
msg: AgentChatMsg;
streaming?: boolean;
}
const MessageRow: React.FC<MessageRowProps> = ({ msg, streaming = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = msg.role === 'user';
const parsed = useMemo(() => {
if (isUser) return null;
return parseAgentResponse(msg.content);
}, [msg.content, isUser]);
if (isUser) {
return (
<div className="flex items-end justify-end gap-3">
<div
className={`max-w-[72%] rounded-2xl px-4 py-3 text-sm leading-relaxed 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">{msg.content}</p>
</div>
<AgentAvatar role="user" />
</div>
);
}
return (
<div className="flex items-start gap-3">
<AgentAvatar role="assistant" isActive={streaming} />
<div className="min-w-0 flex-1">
{parsed ? (
<AssistantAnswer
steps={parsed.steps}
thinkingMarkdown={parsed.thinkingMarkdown}
answerMarkdown={parsed.answerMarkdown}
thinkingComplete={parsed.thinkingComplete && !streaming}
streaming={streaming}
/>
) : (
<div
className={`rounded-2xl border p-4 text-sm ${
isDark
? 'border-slate-600/40 bg-slate-700/60 text-slate-200'
: 'border-gray-100 bg-white text-gray-700'
}`}
>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
</div>
);
};
interface AgentChatMessageListProps {
messages: AgentChatMsg[];
streamingMsgId?: string | null;
agentName?: string;
}
const AgentChatMessageList: React.FC<AgentChatMessageListProps> = ({
messages,
streamingMsgId,
agentName,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (messages.length === 0) {
return (
<div
className={`flex h-full flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
<Bot className="mb-3 h-10 w-10" />
<p className="text-sm">直接与 {agentName || 'MCP 智能体'} 对话以测试当前配置</p>
<p className="mt-1 text-xs">已绑定技能会在步骤区显示实际执行过程</p>
</div>
);
}
return (
<div className="space-y-5">
{messages.map((msg, idx) => {
const isLast = idx === messages.length - 1;
const isStreaming = isLast && msg.role === 'assistant' && msg.id === streamingMsgId;
return <MessageRow key={msg.id} msg={msg} streaming={isStreaming} />;
})}
</div>
);
};
export default AgentChatMessageList;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,248 @@
import React from 'react';
import { Check, Plus, RefreshCcw, Sparkles, UserSearch } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { InlineCollapsible, StageHeader, ThinkingBlock } from './SixStepCards';
import type { ExpertCard, ExpertMatchData } from './types';
interface ExpertCardItemProps {
card: ExpertCard;
index: number;
selected: boolean;
disabled?: boolean;
onSelect: (id: string) => void;
}
const renderAvatar = (avatar: string | undefined, fallback: React.ReactNode, isDark: boolean) => {
if (avatar && avatar.trim().startsWith('<svg')) {
return (
<span
className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl ${
isDark ? 'bg-slate-700/40' : 'bg-white shadow-sm'
}`}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: avatar }}
/>
);
}
return (
<span
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
}`}
>
{fallback}
</span>
);
};
const ExpertCardItem: React.FC<ExpertCardItemProps> = ({ card, index, selected, disabled, onSelect }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const baseBorder = selected
? isDark
? 'border-cyan-300/60 ring-1 ring-cyan-300/40 shadow-lg shadow-cyan-500/10 bg-gradient-to-br from-slate-800/70 to-cyan-900/30'
: 'border-red-300 ring-1 ring-red-200 shadow-md shadow-red-100 bg-gradient-to-br from-white to-red-50/60'
: isDark
? 'border-slate-700/60 bg-gradient-to-br from-slate-800/40 to-slate-900/20 hover:border-cyan-300/40'
: 'border-gray-100 bg-white hover:border-red-200';
return (
<button
type="button"
disabled={disabled}
onClick={() => onSelect(card.id)}
className={`w-full text-left rounded-2xl border p-3 transition-all sixstep-fade-up disabled:cursor-not-allowed disabled:opacity-60 ${baseBorder}`}
>
<div className="flex items-start gap-3">
<span
className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${
isDark
? 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30'
: 'bg-red-50 text-red-600 border-red-200'
}`}
>
{index.toString().padStart(2, '0')}
</span>
{renderAvatar(card.avatarUrl, <UserSearch className="w-5 h-5" />, isDark)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className={`text-sm font-semibold leading-snug truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.name}
</div>
{card.isCustom && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-violet-50 text-violet-600'
}`}
>
新建
</span>
)}
</div>
<div className={`text-xs mt-1 leading-relaxed line-clamp-3 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{card.detail || '暂无描述'}
</div>
</div>
<span
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition ${
selected
? isDark
? 'border-cyan-300 bg-cyan-400/30 text-cyan-100'
: 'border-red-400 bg-red-500 text-white'
: isDark
? 'border-slate-600 text-transparent'
: 'border-gray-300 text-transparent'
}`}
>
<Check className="w-3.5 h-3.5" />
</span>
</div>
</button>
);
};
interface ExpertMatchCardsProps {
data: ExpertMatchData;
onSelectExpert: (id: string) => void;
onRefetch: () => void;
onCreateExpert: () => void;
onConfirm: () => void;
locked?: boolean;
}
const ExpertMatchCards: React.FC<ExpertMatchCardsProps> = ({
data,
onSelectExpert,
onRefetch,
onCreateExpert,
onConfirm,
locked,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isSearching = data.status === 'searching';
const isCreating = data.status === 'creating';
const isReady = data.status === 'ready' || data.status === 'confirmed';
const cards = data.experts ?? [];
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="专家匹配"
desc={
isSearching
? cards.length > 0
? '专家已匹配完成,可选择一位进入下一步'
: '正在调用查找专家 MCP...'
: isCreating
? '正在调用创建专家 MCP...'
: data.status === 'error'
? data.errorMessage || '专家匹配失败'
: '请选择一位专家进入下一步'
}
count={cards.length}
loading={(isSearching && cards.length === 0) || isCreating}
done={data.status === 'confirmed'}
icon={UserSearch}
accent="actor"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={isSearching && cards.length === 0 && !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 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{isSearching && cards.length === 0 ? '正在匹配专家...' : '暂无匹配的专家'}
</div>
) : (
<div className="space-y-2">
{cards.map((card, idx) => (
<ExpertCardItem
key={card.id}
card={card}
index={idx + 1}
selected={data.selectedExpertId === card.id}
disabled={locked}
onSelect={onSelectExpert}
/>
))}
</div>
)}
</InlineCollapsible>
{data.status === 'error' && (
<div className={`rounded-xl border px-3 py-2 text-xs ${
isDark ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' : 'border-rose-200 bg-rose-50 text-rose-700'
}`}>
{data.errorMessage}
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
type="button"
onClick={onRefetch}
disabled={locked || isSearching || isCreating}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'border-slate-600 bg-slate-800/60 text-slate-200 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<RefreshCcw className={`w-3.5 h-3.5 ${isSearching ? 'animate-spin' : ''}`} />
重新查找
</button>
<div className={`flex-1 text-right text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
没有想要的专家?
</div>
<button
type="button"
onClick={onCreateExpert}
disabled={locked || isCreating || isSearching}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'border-violet-400/40 bg-violet-500/15 text-violet-100 hover:bg-violet-500/25'
: 'border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100'
}`}
>
<Plus className="w-3.5 h-3.5" />
创建专家
</button>
<button
type="button"
onClick={onConfirm}
disabled={locked || !data.selectedExpertId || !isReady}
className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
}`}
>
<Sparkles className="w-3.5 h-3.5" />
选择该专家
</button>
</div>
</div>
</InlineCollapsible>
);
};
export default ExpertMatchCards;
@@ -0,0 +1,846 @@
# 前端意图识别与交互集成 — 实现指南
> 对应后端系统:`backend/app/intent/` + `backend/app/orchestrator.py`
> 前端主文件:`SixStepChatPage.tsx`, `api.ts`, `types.ts`, `SixStepConversation.tsx`
> 完成度:**全部 6 步 + follow_up ✅**
---
## 一、核心概念回顾
### 意图体系(Intent System)
前端负责**传递上下文** + **处理 SSE 事件流** + **更新 UI 状态**,后端负责**识别意图** + **分支路由** + **执行业务**。
```
前端按需收集当前状态信息
↓
打包到 IntentOrchestrateRequest
↓
POST /orchestrate-stream (SSE)
↓
后端 detect_intent() 识别意图
↓
根据意图分支,emit SSE 事件
↓
前端处理事件,同步更新 message 和 phase
```
### 七种意图与前端对应
| 意图 | 触发词 | 后端处理 | 前端转换 | 最终状态 |
|------|-------|---------|---------|---------|
| **find_expert** | "找专家" | find_expert MCP | phase: idle → expert-match | 显示专家卡片 + MCP 选择弹窗 |
| **reselect_expert** | "换专家" | find_expert MCP + 过滤 | phase: expert-confirm → expert-match | 显示新的专家卡片 |
| **start_analysis** | "分析" "开始" | 全新 6bf | phase: expert-confirm → generate | 显示 6bf 阶段卡片和流程图 |
| **restart_analysis** | "重新分析" | 清空+重新执行 | phase: completed → generate | 同上 |
| **continue_analysis** | "继续" (在 paused 时) | 从中断点恢复 | phase: paused → generate | 显示 resume 提示 + 继续的卡片 |
| **follow_up** | "为什么" "解释" (在 completed 时) | LLM 回答 | phase: completed → follow-up | Markdown 渲染追问回答 |
| **unknown** | 含糊/歧义 | 请求澄清 | phase: * → clarifying | 显示澄清问题 |
---
## 二、数据模型层(types.ts)
### 2.1 SixStepPhase 枚举
```typescript
export type SixStepPhase =
| 'idle' // 【初始】未开始任何操作
| 'intent-detecting' // 【过渡】意图正在识别中(通常很短,用户看不到)
| 'expert-match' // 【等待】展示专家卡片,用户可浏览、搜索、重选
| 'expert-confirm' // 【交互】MCP 多选弹窗打开,用户确认选择
| 'generate' // 【处理】6bf 编排执行中,显示进度和卡片
| 'paused' // 【中断】用户手动停止;可继续或重新分析
| 'completed' // 【完成】编排全部完成;可追问或重新分析
| 'clarifying' // 【澄清】后端无法理解,请用户补充
| 'follow-up'; // 【追问】正在基于已有分析回答用户问题
```
**关键点**:
- `expert-match` vs `expert-confirm` 的区别:前者是**显示结果**,后者是**交互确认**(MCP 弹窗)
- `paused` **必须**在 `handleStop()` 手动设置,后端的 continue_analysis 分支不会主动发送 paused
- `follow-up` 仅在 `current_phase === 'completed'` 且用户输入匹配追问模式时触发
### 2.2 SixStepMessage 新增字段
```typescript
export interface SixStepMessage {
// ... 原有字段 (id, role, content, timestamp, stages, finalAnswer, ...)
// ========== 意图识别相关 ==========
intent?: {
name: string; // 识别的意图名(find_expert / follow_up / unknown 等)
confidence?: number; // 置信度 [0.0, 1.0]
reason?: string; // 识别理由(用于 debug/日志)
};
// ========== 澄清问题 ==========
clarifyQuestion?: string; // 后端提出的澄清问题(当 intent.name === "unknown")
// ========== 继续分析上下文 ==========
/**
* 原始步骤数据(来自后端 step_end 事件)。
* 结构:BackendStepResult[] = { key, name, status, thinking, structured_outputs, markdown, references }
* 用途:
* - 当用户暂停(handleStop)时,保存这些数据
* - 当用户"继续分析"时,传回后端作为 last_steps,后端可恢复上下文
* - 当用户"追问"时,后端用这些数据构造 context,生成更准确的回答
*/
rawSteps?: BackendStepResult[];
/**
* 本轮是从哪个步骤继续的(来自后端 resume 事件)。
* 值:step key(如 "behavior_factor")或 step name(如 "预期行为与驱动因素")
* UI 用途:在 AssistantMessage 上方显示"⏸ 从 [步骤名] 继续"提示
*/
resumedFrom?: string;
}
```
### 2.3 IntentOrchestrateRequest(api.ts)
```typescript
export interface IntentOrchestrateRequest {
// 意图识别相关
intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert';
// 会话状态(前端告知后端)
current_phase: string;
selected_expert?: ExpertCard | null;
selected_mcps?: SelectableMcpKey[];
// 继续分析相关
last_steps?: BackendStepResult[];
resume_from_step?: string;
expert_candidates?: ExpertCard[]; // 用于 reselect_expert 时过滤
}
```
---
## 三、流处理层(api.ts)
### 3.1 runIntentOrchestrateStream() — 统一 SSE 处理器
```typescript
export async function runIntentOrchestrateStream(
message: string,
options: {
onUpdate?: (msg: SixStepMessage) => void;
onComplete?: () => void;
abortSignal?: AbortSignal;
},
requestBody?: IntentOrchestrateRequest
): Promise<SixStepStreamController>
```
**内部逻辑概述**:
```
1. 发起 POST /orchestrate-stream(带 requestBody 和 AbortSignal)
2. 遍历 SSE 事件流,解析 JSON
3. 根据事件名分发到不同处理函数
4. 每次修改 message 后调用 onUpdate(message),触发前端 re-render
5. 接收 done 或 error 事件时停止
```
### 3.2 关键事件处理函数
#### **intent 事件**
```typescript
// 来自后端:{ intent: "follow_up", confidence: 0.88, reason: "..." }
case 'intent':
const intentName = data.intent;
message.intent = { name: intentName, ...data };
// 👇 根据意图确定流程分支
if (intentName === 'find_expert' || intentName === 'reselect_expert') {
flow = 'expert'; // 接下来期待 expert_start/expert_end
} else if (intentName === 'follow_up') {
flow = 'follow_up'; // 接下来期待 delta(文本块)
message.phase = 'follow-up';
message.outputFormat = 'markdown';
} else if (intentName === 'unknown') {
flow = 'clarify'; // 接下来期待 clarify 事件
} else {
// start_analysis / restart_analysis / continue_analysis
flow = 'generate'; // 接下来期待 start / step_start / step_end
}
break;
```
**前端的 flow 变量**(内部状态,不持久化):用于判断预期的**下一个事件类型**和**数据处理方式**。
#### **expert_start / expert_end 事件**
```typescript
case 'expert_start':
message.phase = 'expert-match';
message.expertMatch = {
status: 'searching',
thinking: '',
experts: [],
selectedExpertId: undefined,
};
break;
case 'expert_end':
// data = { experts: [...], thinking: str, is_reselect: bool, ... }
message.expertMatch!.status = 'ready';
message.expertMatch!.thinking = data.thinking;
// 逐张卡片追加,配合 pop 动画
for (const expert of data.experts) {
message.expertMatch!.experts.push({
id: expert.id,
name: expert.name,
detail: expert.detail,
...
});
// 每张卡片之间延迟 100ms,触发 pop 动画
}
// 卡片全部加载后,更新 phase 并打开 MCP 选择弹窗
message.phase = 'expert-confirm';
// → SixStepChatPage 监听 expertMatch.status === 'ready',打开 mcpModalOpen
break;
```
#### **clarify 事件**
```typescript
case 'clarify':
message.phase = 'clarifying';
message.clarifyQuestion = data.message; // "我没有完全理解你的意图,请补充说明..."
break;
```
#### **resume 事件**(continue_analysis 特有)
```typescript
case 'resume':
// data = { task_id, resume_from, mcp_list: [...] }
message.resumedFrom = data.resume_from; // 后端告诉前端从哪个步骤继续
// UI 用途:在 AssistantMessage 顶部显示 "⏸ 从 [步骤名] 继续"
// 该变量会保留在消息中,整个 continue 过程结束后仍显示
break;
```
#### **start 事件**(开始 6bf 编排)
```typescript
case 'start':
message.phase = 'generate';
// data = { task_id, mcp_list: [...], ts: number }
// 👇 防止 restart_analysis 时保留旧卡片
if (!isResume) { // isResume 由 resume 事件设置,continue_analysis 时为 true
message.stages = {}; // 清空
message.subStepStatus = {};
message.mainStepStatus = { info: 'done', sixstep: 'pending', final: 'pending' };
}
// 若 isResume === true(continue_analysis),则保留已有的 stages,只追加新步骤
message.currentMainStep = 'sixstep';
break;
```
**关键**:`!isResume` 的条件确保 **restart_analysis 时清空旧卡片**,而 **continue_analysis 时保留已有卡片**。
#### **step_start / step_end 事件**
```typescript
case 'step_start':
// data = { name: "目的分析", key: "purpose" }
const stepKey = data.key;
if (stepKey === 'purpose') {
message.subStepStatus['purpose'] = 'running';
message.currentSubStep = 'purpose';
message.stages!.purpose = { thinking: '', cards: [], complete: false };
}
// ... 类似处理 actor / behavior / narrative
break;
case 'step_end':
// data = { key, status, thinking, structured_outputs, markdown, references, ... }
const stepKey = data.key;
// 👇 原始数据存档,用于后续 continue_analysis / follow_up
if (stepKey !== 'info_prepare') {
message.rawSteps = message.rawSteps || [];
message.rawSteps.push(data); // 保存完整的 BackendStepResult
}
// 更新当前消息的对应阶段
if (stepKey === 'purpose') {
message.stages!.purpose!.thinking = data.thinking;
message.stages!.purpose!.cards = buildPurposeCards(data.structured_outputs);
message.stages!.purpose!.complete = true;
message.subStepStatus['purpose'] = 'done';
}
// ... 类似处理 actor / behavior / narrative
break;
```
#### **delta 事件**(follow_up 专有)
```typescript
case 'delta':
// data = { text: "..." }(文本块)
// 仅当 flow === 'follow_up' 时处理
if (flow === 'follow_up') {
message.markdownContent = (message.markdownContent || '') + data.text;
onUpdate(message); // 逐块更新前端,流式显示
}
break;
```
#### **done 事件**
```typescript
case 'done':
// 各分支的终止信号
if (flow === 'follow_up') {
message.phase = 'completed'; // ✅ follow_up 回答完成
} else if (flow === 'expert') {
// 专家查询完成,等待前端用户在弹窗中确认选择
// phase 保持 'expert-confirm'
} else if (flow === 'generate') {
message.phase = 'completed'; // ✅ 6bf 推演完成
} else if (flow === 'clarify') {
// 保持 'clarifying',等待用户重新输入
}
onComplete?.(); // 回调:请求完成
break;
```
---
## 四、主页面逻辑(SixStepChatPage.tsx)
### 4.1 新增状态变量
```typescript
// 当前阶段(驱动 UI 和后端意图识别)
const [currentPhase, setCurrentPhase] = useState<SixStepPhase>('idle');
// 已选专家和 MCP(用于请求体和 UI 展示)
const [selectedExpert, setSelectedExpert] = useState<ExpertCard | null>(null);
const [selectedMcps, setSelectedMcps] = useState<SelectableMcpKey[]>([]);
// 已推荐过的专家列表(reselect_expert 时用于过滤)
const [expertCandidates, setExpertCandidates] = useState<ExpertCard[]>([]);
// 临时保存原始用户输入(确认专家后需要)
const [lastUserInput, setLastUserInput] = useState<string>('');
```
### 4.2 handleSend() — 请求体构造
```typescript
async function handleSend(inputText: string) {
setLastUserInput(inputText); // 保存原始输入
// 【1】获取上次的助手消息(用于获取 rawSteps)
const lastAssistant = messages.findLast(m => m.role === 'assistant');
// 【2】构造请求体(关键!传递上下文给后端)
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'auto', // 自动意图识别
current_phase: currentPhase,
selected_expert: selectedExpert,
selected_mcps: selectedMcps,
last_steps: lastAssistant?.rawSteps || [], // 👈 用于 continue / follow_up
resume_from_step: undefined, // 让后端自动推断
expert_candidates: expertCandidates, // 👈 用于 reselect_expert 过滤
};
// 【3】调用流处理
const controller = await runIntentOrchestrateStream(
inputText,
{
onUpdate: (msg) => {
// 替换消息列表中的末尾消息(正在生成的消息)
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = msg;
return newMessages;
});
// 同步更新当前阶段
setCurrentPhase(msg.phase || 'idle');
// 【关键】监听专家卡片加载完成,打开 MCP 选择弹窗
if (msg.expertMatch?.status === 'ready') {
setMcpModalOpen(true);
}
},
onComplete: () => {
setIsGenerating(false);
},
},
requestBody
);
controllerRef.current = controller;
setIsGenerating(true);
}
```
**核心三步**:
1. **构造请求体**:携带所有必要的上下文(current_phase, last_steps, expert_candidates)
2. **调用流处理**:传入 onUpdate 回调,每个 SSE 事件都更新前端
3. **监听事件**:特别是 expertMatch.status === 'ready',自动打开 MCP 弹窗
### 4.3 handleMcpModalConfirm() — 确认专家后立即启动分析
```typescript
function handleMcpModalConfirm(selectedExpert: ExpertCard, selectedMcpList: SelectableMcpKey[]) {
// 【1】保存用户选择
setSelectedExpert(selectedExpert);
setSelectedMcps(selectedMcpList);
setExpertCandidates(prev => [...prev, selectedExpert]); // 添加到候选列表
// 【2】立即用 force_orchestrate 启动分析(不需要等用户再次输入)
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'force_orchestrate', // 👈 强制执行编排,不识别意图
current_phase: 'expert-confirm',
selected_expert: selectedExpert,
selected_mcps: selectedMcpList,
last_steps: [],
};
// 【3】使用保存的原始输入(用户之前在输入框里输入的文本)
runIntentOrchestrateStream(
lastUserInput, // 👈 原始输入
{
onUpdate: (msg) => {
setMessages(prev => [...prev.slice(0, -1), msg]);
setCurrentPhase(msg.phase || 'idle');
},
onComplete: () => setIsGenerating(false),
},
requestBody
);
}
```
**关键点**:
- `intent_mode: 'force_orchestrate'` — 跳过意图识别,直接执行 6bf
- 使用 `lastUserInput`(保存的原始输入)而非动态输入
- 添加 selectedExpert 到 expertCandidates,以便后续 reselect 时过滤
### 4.4 handleRefetchExpert() — 重选专家
```typescript
function handleRefetchExpert() {
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'force_find_expert', // 👈 强制查询专家
current_phase: 'expert-confirm',
expert_candidates: expertCandidates, // 👈 告诉后端已有候选,避免重复推荐
last_steps: [],
};
runIntentOrchestrateStream(
lastUserInput,
{
onUpdate: (msg) => {
setMessages(prev => [...prev.slice(0, -1), msg]);
setCurrentPhase(msg.phase || 'idle');
// MCP 弹窗保持打开,等待用户重新选择
},
},
requestBody
);
}
```
### 4.5 handleStop() — 停止生成
```typescript
function handleStop() {
controllerRef.current?.stop();
// 👇 关键:标记当前消息为 paused,供后续 continue_analysis 识别
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.phase = 'paused';
}
return newMessages;
});
setCurrentPhase('paused');
setIsGenerating(false);
}
```
**为什么需要标记 paused?**
- 后端的 intent 识别器会检查 `current_phase === 'paused'`,优先返回 `continue_analysis` 意图
- 前端显示 "⏸ 从 [步骤] 继续" 提示,让用户清楚地知道可以继续
---
## 五、对话区渲染(SixStepConversation.tsx)
### 5.1 AssistantMessage 的新渲染逻辑
```typescript
const AssistantMessage = ({ message, active }: Props) => {
const isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm';
const isFollowUp = message.phase === 'follow-up';
const isClarifying = message.phase === 'clarifying';
const isMarkdown = message.outputFormat === 'markdown';
// 👇 【防护条件】防止旧分析内容在专家/澄清/追问阶段出现
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
return (
<div>
{/* 【分支 1】专家匹配阶段 */}
{isExpertPhase && message.expertMatch && (
<ExpertMatchCards
data={message.expertMatch}
onRefetch={onRefetch}
onConfirm={onConfirm}
/>
)}
{/* 【分支 2】6bf 分析阶段(防护条件) */}
{showAnalysisContent && (
<>
{/* 继续分析提示 */}
{message.resumedFrom && (
<div className="resume-hint">
⏸ 从「{message.resumedFrom}」继续
</div>
)}
{/* 各阶段卡片 */}
{message.stages?.info && <InfoStage ... />}
{message.stages?.purpose && <PurposeStage ... />}
{message.stages?.actor && <ActorStage ... />}
{message.stages?.behavior && <BehaviorStage ... />}
{message.stages?.narrative && <NarrativeStage ... />}
{message.finalAnswer && <FinalAnswer ... />}
</>
)}
{/* 【分支 3】追问回答(Markdown) */}
{isFollowUp && message.markdownContent && (
<MarkdownRenderer content={message.markdownContent} />
)}
{/* 【分支 4】Markdown 输出格式 */}
{isMarkdown && !isFollowUp && message.markdownContent && (
<MarkdownRenderer content={message.markdownContent} />
)}
{/* 【分支 5】澄清问题 */}
{isClarifying && message.clarifyQuestion && (
<div className="clarify-block">
<p className="text-sm text-gray-600">我需要你进一步说明:</p>
<p className="font-medium">{message.clarifyQuestion}</p>
<p className="text-xs text-gray-500 mt-2">请重新输入以补充信息</p>
</div>
)}
</div>
);
};
```
**关键防护条件**:
```typescript
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
```
这一行确保了:
- ✅ `expert-match` 阶段只显示专家卡片,不显示旧分析
- ✅ `clarifying` 阶段只显示澄清问题,不显示其他内容
- ✅ `follow-up` 阶段只显示 Markdown 回答,不显示旧分析
### 5.2 阶段加载动画
```typescript
// 当前阶段正在生成时,显示 shimmer(流光)动画
const purposeGenerating = message.phase === 'generate' &&
message.currentSubStep === 'purpose' &&
active;
<StageHeader
loading={purposeGenerating}
icon={<Target ... />}
title="目的分析"
count={message.stages?.purpose?.cards.length || 0}
/>
```
---
## 六、生命周期示例
### 6.1 用户"找专家"的流程
```
【用户输入】"帮我找个专家"
↓
【前端】handleSend("帮我找个专家")
- requestBody: { intent_mode: 'auto', current_phase: 'idle', last_steps: [] }
- 调用 runIntentOrchestrateStream
↓
【后端】detect_intent("帮我找个专家", current_phase='idle', ...)
- 规则识别:匹配 _FIND_PATTERNS → confidence=0.88
- 返回:IntentResult(intent='find_expert', ...)
↓
【后端】根据 intent='find_expert',调用 _stream_find_expert()
- emit: expert_start
- 查询 find_expert MCP agent
- emit: expert_end { experts: [...] }
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'find_expert', ... }
→ flow = 'expert'
- expert_start
→ message.phase = 'expert-match'
→ message.expertMatch.status = 'searching'
- expert_end
→ message.expertMatch.status = 'ready'
→ message.expertMatch.experts = [...]
→ message.phase = 'expert-confirm'
→ 检测到 expertMatch.status === 'ready'
→ setMcpModalOpen(true) 打开 MCP 选择弹窗
- done
→ onComplete()
↓
【用户】在 MCP 选择弹窗中选择专家和 MCP,点击"确认"
↓
【前端】handleMcpModalConfirm(selectedExpert, selectedMcps)
- setSelectedExpert(selectedExpert)
- setSelectedMcps(selectedMcps)
- requestBody: { intent_mode: 'force_orchestrate', ... }
- 调用 runIntentOrchestrateStream(lastUserInput, ...)
↓
【后端】orchestrate_stream() with intent_mode='force_orchestrate'
- 跳过意图识别
- 调用 _stream_orchestrate(..., intent_name='start_analysis')
- emit: start
- emit: step_start / step_end × 4 (purpose / actor / behavior / narrative)
- emit: flow { nodes, edges }
- emit: done
↓
【前端】处理 SSE 事件
- start
→ message.phase = 'generate'
→ 初始化 stages 和状态
- step_start × 4
→ 每个步骤的 status = 'running'
→ 对应阶段初始化为空
- step_end × 4
→ 填充 stages[stepKey].cards
→ message.rawSteps.push(data)
→ status = 'done'
- flow
→ 流程图数据更新(供沙箱显示)
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示 4 个阶段的卡片
- ✅ 显示流程图沙箱
- ✅ 阶段完成时有 pop 动画和叙事卡片
```
### 6.2 用户"继续分析"的流程
```
【前次】完成了目的、行为体阶段,用户点击"停止"
- 后端返回 step_end(预期行为/驱动因素)但用户取消
- message.phase = 'paused'
- message.rawSteps = [
{ key: 'purpose', status: 'completed', ... },
{ key: 'actor', status: 'completed', ... },
{ key: 'behavior_factor', status: 'running', ... }
]
↓
【用户输入】"继续"
↓
【前端】handleSend("继续")
- requestBody: {
intent_mode: 'auto',
current_phase: 'paused', // 👈 关键
last_steps: message.rawSteps, // 👈 关键
}
↓
【后端】detect_intent("继续", current_phase='paused', last_steps=[...])
- 规则识别:匹配 _CONTINUE_PATTERNS AND current_phase='paused'
- 返回:IntentResult(intent='continue_analysis', confidence=0.90)
↓
【后端】_stream_orchestrate(..., intent_name='continue_analysis')
- _resume_context_from_steps(req.last_steps, None)
→ 检查已完成的步骤:['purpose', 'actor']
→ resume_key = 'behavior_factor'(下一个未完成的)
- emit: resume { resume_from: 'behavior_factor', mcp_list: ['behavior_factor', 'narrative'] }
- emit: start { mcp_list: ['behavior_factor', 'narrative'] }
- emit: step_start / step_end × 2 (behavior_factor, narrative)
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'continue_analysis', ... }
- resume: { resume_from: 'behavior_factor' }
→ message.resumedFrom = 'behavior_factor'
→ isResume = true // 标记为继续,不清空旧 stages
- start
→ 因为 isResume=true,不清空 message.stages
→ 保留已有的 purpose/actor 卡片
- step_start / step_end × 2
→ 追加新的 behavior/narrative 卡片
→ message.rawSteps.push(behavior_data)
→ message.rawSteps.push(narrative_data)
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示"⏸ 从「预期行为与驱动因素」继续"提示
- ✅ 保留旧的目的/行为体卡片
- ✅ 追加新的预期行为/叙事卡片
```
### 6.3 用户"追问"的流程
```
【前次】完成了完整的 6bf,message.phase = 'completed'
- message.rawSteps = [
{ key: 'purpose', ... },
{ key: 'actor', ... },
{ key: 'behavior_factor', ... },
{ key: 'narrative', ... }
]
↓
【用户输入】"第一条是什么意思"
↓
【前端】handleSend("第一条是什么意思")
- requestBody: {
intent_mode: 'auto',
current_phase: 'completed', // 👈 关键
last_steps: message.rawSteps,
}
↓
【后端】detect_intent("第一条是什么意思", current_phase='completed', last_steps=[...])
- 规则识别:匹配 _FOLLOW_UP_PATTERNS(r"第[一二三四五六七八九十\d]+[点条个项]")
- 返回:IntentResult(intent='follow_up', confidence=0.88)
↓
【后端】_stream_follow_up(req, ...)
- 提取 req.last_steps 中已完成步骤的数据
- 构造 context_str(包含目的、行为体、预期行为、叙事)
- 调用 LLM 流式回答
- emit: delta { text: "..." } × N
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'follow_up', ... }
→ flow = 'follow_up'
→ message.phase = 'follow-up'
→ message.outputFormat = 'markdown'
- delta × N
→ message.markdownContent += text
→ 每块更新都触发 onUpdate,前端流式显示
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示 Markdown 格式的追问回答
- ✅ 不显示旧的 6bf 卡片(因为 showAnalysisContent = false)
- ✅ 流式效果:用户看到实时生成的文本
```
---
## 七、常见问题解决
### Q1: 完成 6bf 后再选专家,为什么显示了旧的分析内容?
**原因**:AssistantMessage 中没有正确应用 `showAnalysisContent` 防护条件。
**检查**:
```typescript
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
```
确保:
- `isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm'` ✓
- 所有 `stages` / `finalAnswer` 的渲染都被 `{showAnalysisContent && (...)}` 包裹 ✓
### Q2: 继续分析后,旧卡片消失了
**原因**:SSE start 事件中清空了 stages(应该只在 !isResume 时清空)。
**检查** `api.ts` 中的 start 事件处理:
```typescript
case 'start':
if (!isResume) { // 👈 这个条件必须有
message.stages = {};
}
// ...
break;
```
### Q3: 用户点"继续",但后端返回了"开始新分析"
**原因**:`current_phase` 未传递为 'paused',或未手动设置 `phase = 'paused'`。
**检查** `SixStepChatPage.handleStop()`:
```typescript
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.phase = 'paused'; // 👈 必须设置
}
return newMessages;
});
setCurrentPhase('paused'); // 👈 必须同步状态
```
### Q4: 追问回答为空
**原因**:
1. `current_phase !== 'completed'`(后端不识别为 follow_up)
2. `last_steps` 为空或格式错误
3. LLM 调用失败(查看后端日志)
**检查**:
- 确保 message.rawSteps 在 step_end 时被正确赋值
- 确保 request 中 `last_steps` 被传递为 `message.rawSteps`
- 后端日志是否有 `_stream_follow_up` 的报错
---
## 八、开发建议
1. **调试技巧**:在 `onUpdate` 中 `console.log(message)` 观察每个事件的状态变化
2. **SSE 事件顺序不可打破**:前端严格依赖事件顺序做状态机转换
3. **rawSteps 的完整性**:非 info_prepare 的所有 step_end 都必须存档,否则 continue/follow_up 会丢失数据
4. **phase 与 outputFormat 的对应**:
- follow-up → outputFormat = 'markdown'
- generate → outputFormat = 'structured'(卡片渲染)
- expert-match → 无 outputFormat(专家卡片独立)
---
**相关文件导航**:
- 后端意图系统:`backend/app/intent/`
- 后端编排系统:`backend/app/orchestrator.py`
- 前端核心:`SixStepChatPage.tsx` (state hub) → `api.ts` (SSE handler) → `SixStepConversation.tsx` (rendering)
- 前端类型:`types.ts`
- 前端原始文档:`doc.md`
@@ -0,0 +1,233 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Check, Megaphone, Target, Users, X, Zap } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { ExpertCard, SelectableMcpKey } from './types';
interface McpOption {
key: SelectableMcpKey;
name: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
}
const MCP_OPTIONS: McpOption[] = [
{ key: 'purpose', name: '目的', description: '识别本次推演的核心目的,构成后续推理的锚点。', icon: Target, accent: 'purpose' },
{ key: 'actor', name: '行为体', description: '推演支撑目的所需的关键行为体角色。', icon: Users, accent: 'actor' },
{ key: 'behavior_factor', name: '预期行为和驱动因素', description: '为每个行为体推演预期行为及背后的驱动因素。', icon: Zap, accent: 'behavior' },
{ key: 'narrative', name: '叙事', description: '基于驱动因素构建可被复用的叙事框架。', icon: Megaphone, accent: 'narrative' },
];
const ACCENT_LIGHT: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
purpose: {
selected: 'border-orange-300 bg-orange-50/70 ring-1 ring-orange-200',
idle: 'border-gray-200 bg-white hover:border-orange-200',
chip: 'bg-orange-50 text-orange-600',
},
actor: {
selected: 'border-red-300 bg-red-50/70 ring-1 ring-red-200',
idle: 'border-gray-200 bg-white hover:border-red-200',
chip: 'bg-red-50 text-red-600',
},
behavior: {
selected: 'border-emerald-300 bg-emerald-50/70 ring-1 ring-emerald-200',
idle: 'border-gray-200 bg-white hover:border-emerald-200',
chip: 'bg-emerald-50 text-emerald-600',
},
narrative: {
selected: 'border-amber-300 bg-amber-50/70 ring-1 ring-amber-200',
idle: 'border-gray-200 bg-white hover:border-amber-200',
chip: 'bg-amber-50 text-amber-600',
},
};
const ACCENT_DARK: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
purpose: {
selected: 'border-violet-300/50 bg-violet-500/15 ring-1 ring-violet-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-violet-300/40',
chip: 'bg-violet-500/20 text-violet-200',
},
actor: {
selected: 'border-cyan-300/50 bg-cyan-500/15 ring-1 ring-cyan-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-cyan-300/40',
chip: 'bg-cyan-500/20 text-cyan-200',
},
behavior: {
selected: 'border-emerald-300/50 bg-emerald-500/15 ring-1 ring-emerald-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-emerald-300/40',
chip: 'bg-emerald-500/20 text-emerald-200',
},
narrative: {
selected: 'border-amber-300/50 bg-amber-500/15 ring-1 ring-amber-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-amber-300/40',
chip: 'bg-amber-500/20 text-amber-200',
},
};
interface Props {
open: boolean;
expert: ExpertCard | null;
initialSelected?: SelectableMcpKey[];
onCancel: () => void;
onConfirm: (selected: SelectableMcpKey[]) => void;
}
const McpSelectModal: React.FC<Props> = ({ open, expert, initialSelected, onCancel, onConfirm }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const defaultAll = useMemo<SelectableMcpKey[]>(() => MCP_OPTIONS.map((m) => m.key), []);
const [selected, setSelected] = useState<SelectableMcpKey[]>(
initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll,
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setSelected(initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll);
setError(null);
}
}, [defaultAll, initialSelected, open]);
if (!open) return null;
const toggle = (key: SelectableMcpKey) => {
setSelected((prev) => {
const has = prev.includes(key);
if (has) {
if (prev.length <= 1) {
setError('至少选择一个生成步骤');
return prev;
}
setError(null);
return prev.filter((k) => k !== key);
}
setError(null);
return [...prev, key];
});
};
const handleConfirm = () => {
if (selected.length === 0) {
setError('至少选择一个生成步骤');
return;
}
const ordered = MCP_OPTIONS.map((m) => m.key).filter((k) => selected.includes(k));
onConfirm(ordered);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onCancel}
role="presentation"
/>
<div
className={`relative z-10 w-full max-w-xl rounded-2xl border shadow-2xl ${
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-200 bg-white text-gray-900'
}`}
>
<div className={`flex items-center justify-between border-b px-5 py-4 ${
isDark ? 'border-slate-700' : 'border-gray-100'
}`}>
<div className="min-w-0">
<div className="text-base font-semibold">选择本次执行的 MCP</div>
<div className={`text-xs mt-0.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{expert ? `专家:${expert.name}` : '默认全部勾选,至少保留 1 个步骤'}
</div>
</div>
<button
type="button"
onClick={onCancel}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDark ? 'text-slate-400 hover:bg-slate-800' : 'text-gray-400 hover:bg-gray-100'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto px-5 py-4 space-y-2">
{MCP_OPTIONS.map((opt) => {
const isSelected = selected.includes(opt.key);
const palette = isDark ? ACCENT_DARK[opt.accent] : ACCENT_LIGHT[opt.accent];
const Icon = opt.icon;
return (
<button
key={opt.key}
type="button"
onClick={() => toggle(opt.key)}
className={`w-full text-left rounded-xl border px-3 py-3 transition ${
isSelected ? palette.selected : palette.idle
}`}
>
<div className="flex items-start gap-3">
<span className={`flex h-9 w-9 items-center justify-center rounded-xl shrink-0 ${palette.chip}`}>
<Icon className="w-4 h-4" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold">{opt.name}</div>
<div className={`text-xs mt-0.5 leading-relaxed ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
{opt.description}
</div>
</div>
<span
className={`shrink-0 mt-0.5 flex h-5 w-5 items-center justify-center rounded-md border transition ${
isSelected
? isDark
? 'border-cyan-300 bg-cyan-400/40 text-cyan-50'
: 'border-red-400 bg-red-500 text-white'
: isDark
? 'border-slate-600 text-transparent'
: 'border-gray-300 text-transparent'
}`}
>
<Check className="w-3 h-3" />
</span>
</div>
</button>
);
})}
{error && (
<div className={`text-xs ${isDark ? 'text-rose-300' : 'text-rose-600'}`}>{error}</div>
)}
</div>
<div className={`flex items-center justify-between gap-3 border-t px-5 py-3 ${
isDark ? 'border-slate-700' : 'border-gray-100'
}`}>
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
后端固定执行顺序:目的 → 行为体 → 预期行为和驱动因素 → 叙事
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition ${
isDark
? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
取消
</button>
<button
type="button"
onClick={handleConfirm}
className={`rounded-lg px-3 py-1.5 text-sm font-semibold text-white transition ${
isDark
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
}`}
>
确认
</button>
</div>
</div>
</div>
</div>
);
};
export default McpSelectModal;
@@ -0,0 +1,360 @@
import React from 'react';
import {
ChevronDown,
Compass,
FileSearch,
Megaphone,
ScrollText,
Sparkles,
Target,
TrendingUp,
UserCheck,
Users,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type {
ActorCard,
BehaviorDriverCard,
NarrativeCard,
PurposeCard,
} from './types';
interface CollapsibleProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
variant?: 'default' | 'subtle';
}
export const InlineCollapsible: React.FC<CollapsibleProps> = ({ title, children, defaultOpen = true, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = React.useState(defaultOpen);
const wrapperClass = variant === 'subtle'
? `${isDark ? 'border-slate-700/50 bg-slate-900/30' : 'border-gray-100 bg-white/80'}`
: `${isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-red-100 bg-white'}`;
return (
<div className={`rounded-2xl border overflow-hidden ${wrapperClass}`}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${
open ? 'rotate-0' : '-rotate-90'
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
/>
</button>
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}>
<div className="overflow-hidden">
<div className="px-4 pb-4 pt-1">{children}</div>
</div>
</div>
</div>
);
};
interface CardIndexBadgeProps {
index: number;
variant: 'purpose' | 'actor' | 'behavior' | 'narrative';
}
const VARIANT_COLOR: Record<CardIndexBadgeProps['variant'], { dark: string; light: string }> = {
purpose: { dark: 'bg-violet-500/20 text-violet-200 border-violet-400/30', light: 'bg-orange-50 text-orange-600 border-orange-200' },
actor: { dark: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30', light: 'bg-red-50 text-red-600 border-red-200' },
behavior: { dark: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30', light: 'bg-emerald-50 text-emerald-600 border-emerald-200' },
narrative: { dark: 'bg-amber-500/20 text-amber-200 border-amber-400/30', light: 'bg-amber-50 text-amber-600 border-amber-200' },
};
const CardIndexBadge: React.FC<CardIndexBadgeProps> = ({ index, variant }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const cls = VARIANT_COLOR[variant][isDark ? 'dark' : 'light'];
return (
<span className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${cls}`}>
{index.toString().padStart(2, '0')}
</span>
);
};
export const PurposeCardItem: React.FC<{ card: PurposeCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`group rounded-2xl border p-3 transition-all sixstep-fade-up ${
isDark
? 'border-violet-400/20 bg-gradient-to-br from-slate-800/60 to-violet-900/20 hover:border-violet-300/40'
: 'border-orange-100 bg-gradient-to-br from-white to-orange-50/40 hover:border-orange-200'
}`}>
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="purpose" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-orange-50 text-orange-500'
}`}>
<Target className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.title}
</div>
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-violet-200' : 'text-orange-600'}`}>
<Compass className="w-3 h-3" />理由
</span>
{card.reason}
</div>
</div>
</div>
</div>
);
};
export const ActorCardItem: React.FC<{ card: ActorCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
isDark
? 'border-cyan-400/20 bg-gradient-to-br from-slate-800/60 to-cyan-900/20 hover:border-cyan-300/40'
: 'border-red-100 bg-gradient-to-br from-white to-red-50/40 hover:border-red-200'
}`}>
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="actor" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
}`}>
<UserCheck className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.title}
</div>
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{card.content}
</div>
</div>
</div>
</div>
);
};
export const BehaviorCardItem: React.FC<{ card: BehaviorDriverCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border overflow-hidden transition-all sixstep-fade-up ${
isDark
? 'border-emerald-400/20 bg-gradient-to-br from-slate-800/60 to-emerald-900/20 hover:border-emerald-300/40'
: 'border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40 hover:border-emerald-200'
}`}>
<div className="p-3">
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="behavior" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-50 text-emerald-600'
}`}>
<TrendingUp className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
预期行为
</div>
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.expectedBehavior}
</div>
</div>
</div>
</div>
<div className={`border-t px-3 py-3 ${
isDark ? 'border-emerald-400/15 bg-emerald-500/5' : 'border-emerald-100 bg-emerald-50/40'
}`}>
<div className="flex items-start gap-2">
<Zap className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
<div className="flex-1 min-w-0">
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
驱动因素
</div>
<div className={`text-xs leading-relaxed ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{card.driver}
</div>
</div>
</div>
</div>
</div>
);
};
export const NarrativeCardItem: React.FC<{ card: NarrativeCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
isDark
? 'border-amber-400/20 bg-gradient-to-br from-slate-800/60 to-amber-900/20 hover:border-amber-300/40'
: 'border-amber-100 bg-gradient-to-br from-white to-amber-50/40 hover:border-amber-200'
}`}>
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="narrative" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-amber-500/20 text-amber-200' : 'bg-amber-50 text-amber-600'
}`}>
<ScrollText className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.title}
</div>
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-amber-200' : 'text-amber-600'}`}>
<Sparkles className="w-3 h-3" />理由
</span>
{card.reason}
</div>
</div>
</div>
</div>
);
};
interface GroupHeaderProps {
label: string;
parentTitle: string;
count: number;
variant: 'actor' | 'behavior' | 'narrative';
}
export const ParentGroupHeader: React.FC<GroupHeaderProps> = ({ label, parentTitle, count, variant }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = variant === 'actor' ? Target : variant === 'behavior' ? Users : Zap;
const accent = variant === 'actor'
? (isDark ? 'text-violet-200 bg-violet-500/15 border-violet-400/30' : 'text-orange-600 bg-orange-50 border-orange-200')
: variant === 'behavior'
? (isDark ? 'text-cyan-200 bg-cyan-500/15 border-cyan-400/30' : 'text-red-600 bg-red-50 border-red-200')
: (isDark ? 'text-emerald-200 bg-emerald-500/15 border-emerald-400/30' : 'text-emerald-600 bg-emerald-50 border-emerald-200');
return (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border w-fit ${accent}`}>
<Icon className="w-3.5 h-3.5" />
<span className="text-[11px] font-medium">{label}</span>
<span className={`text-xs font-semibold ${isDark ? 'text-white' : 'text-gray-900'} truncate max-w-[260px]`} title={parentTitle}>
{parentTitle}
</span>
<span className={`text-[10px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>· {count} 张</span>
</div>
);
};
interface ThinkingBlockProps {
text: string;
loading?: boolean;
empty?: string;
}
export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ text, loading, empty = '正在生成思考内容...' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border px-4 py-3 ${
isDark ? 'bg-slate-900/30 border-slate-700/60' : 'bg-white border-gray-100'
}`}>
<div className="flex items-center gap-2 mb-2">
<FileSearch className={`w-3.5 h-3.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
<span className={`text-[11px] font-medium uppercase tracking-wider ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考过程
</span>
{loading && (
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
生成中
</span>
)}
</div>
{text ? (
<pre className={`whitespace-pre-wrap text-xs leading-relaxed font-sans ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{text}
</pre>
) : (
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{empty}</div>
)}
</div>
);
};
interface StageHeaderProps {
title: string;
desc?: string;
count?: number;
loading?: boolean;
done?: boolean;
icon: React.ComponentType<{ className?: string }>;
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
}
const ACCENT_BG: Record<StageHeaderProps['accent'], { dark: string; light: string }> = {
purpose: { dark: 'bg-violet-500/15 text-violet-200', light: 'bg-orange-50 text-orange-500' },
actor: { dark: 'bg-cyan-500/15 text-cyan-200', light: 'bg-red-50 text-red-500' },
behavior: { dark: 'bg-emerald-500/15 text-emerald-200', light: 'bg-emerald-50 text-emerald-600' },
narrative: { dark: 'bg-amber-500/15 text-amber-200', light: 'bg-amber-50 text-amber-600' },
};
export const StageHeader: React.FC<StageHeaderProps> = ({ title, desc, count, loading, done, icon: Icon, accent }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const cls = ACCENT_BG[accent][isDark ? 'dark' : 'light'];
return (
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${cls} ${loading ? 'sixstep-pulse' : ''}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'} flex items-center gap-2`}>
<span className="truncate">{title}</span>
{typeof count === 'number' && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-600'
}`}>
{count} 张
</span>
)}
{done && (
<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>
)}
{loading && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-600'
}`}>
生成中
</span>
)}
</div>
{desc && (
<div className={`text-xs mt-0.5 ${
loading ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-slate-400' : 'text-gray-500')
}`}>
{desc}
</div>
)}
</div>
</div>
);
};
export const STAGE_ICONS = {
purpose: Target,
actor: Users,
behavior: Zap,
narrative: Megaphone,
};
@@ -0,0 +1,346 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, Eraser, MessageSquareText, Pencil, Plus, Trash2, X } from 'lucide-react';
export interface SixStepConversationMeta {
id: string;
title: string;
updatedAt: number;
}
interface Props {
isOpen: boolean;
isDark: boolean;
conversations: SixStepConversationMeta[];
activeId: string | null;
onClose: () => void;
onOpen: () => void;
onSelect: (id: string) => void;
onNew: () => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onClearAll: () => void;
}
const UNTITLED = '未命名对话';
const SixStepChatHistory: React.FC<Props> = ({
isOpen,
isDark,
conversations,
activeId,
onClose,
onOpen,
onSelect,
onNew,
onDelete,
onRename,
onClearAll,
}) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
const startEdit = (conversation: SixStepConversationMeta) => {
setEditingId(conversation.id);
setDraftTitle(conversation.title);
};
const commitEdit = () => {
if (!editingId) return;
onRename(editingId, draftTitle.trim() || UNTITLED);
setEditingId(null);
setDraftTitle('');
};
const cancelEdit = () => {
setEditingId(null);
setDraftTitle('');
};
const handleDelete = (conversation: SixStepConversationMeta) => {
const ok = window.confirm(`确认删除对话“${conversation.title || UNTITLED}”吗?`);
if (!ok) return;
onDelete(conversation.id);
};
const handleClearAll = () => {
if (conversations.length === 0) return;
const ok = window.confirm('确认清空当前对话列表中的全部历史记录吗?此操作无法撤销。');
if (!ok) return;
onClearAll();
};
const railButtonClass = (active = false) =>
`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border transition ${
active
? isDark
? 'border-cyan-400/40 bg-cyan-500/10 text-cyan-200 shadow-sm'
: 'border-red-200 bg-red-50/70 text-red-500 shadow-sm'
: isDark
? 'border-slate-700 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900'
}`;
const handleCollapsedSelect = (id: string) => {
onOpen();
window.setTimeout(() => {
onSelect(id);
}, 120);
};
const handleCollapsedNew = () => {
onOpen();
window.setTimeout(() => {
onNew();
}, 120);
};
return (
<aside
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity] duration-300 ease-out ${
isOpen ? 'pointer-events-auto w-[300px] opacity-100' : 'pointer-events-auto w-[72px] opacity-100'
} ${
isDark
? 'border-slate-700/50 bg-slate-900/60 text-slate-100'
: 'border-red-100 bg-gray-50/70 text-gray-900'
}`}
>
{isOpen ? (
<>
<div
className={`border-b px-4 py-4 transition-all duration-300 ease-out ${isDark ? 'border-slate-700/50' : 'border-red-100'} ${
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">聊天记录</h3>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
查看和管理当前页面的历史对话
</p>
</div>
<button
type="button"
onClick={onClose}
title="收起聊天记录"
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
onClick={onNew}
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition ${
isDark
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-red-200 bg-white text-red-600 hover:bg-red-50'
}`}
>
<Plus className="h-4 w-4" />
新建对话
</button>
<button
type="button"
onClick={handleClearAll}
disabled={conversations.length === 0}
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Eraser className="h-4 w-4" />
清空记录
</button>
</div>
</div>
<div
className={`min-h-0 flex-1 space-y-1 overflow-y-auto px-3 py-3 transition-all duration-300 ease-out ${
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
}`}
>
{conversations.length === 0 ? (
<div
className={`rounded-xl border border-dashed px-3 py-8 text-center text-xs ${
isDark ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
}`}
>
还没有任何对话记录
</div>
) : (
conversations.map((conversation) => {
const isActive = activeId === conversation.id;
const isEditing = editingId === conversation.id;
return (
<div
key={conversation.id}
role={isEditing ? undefined : 'button'}
tabIndex={isEditing ? -1 : 0}
onClick={() => !isEditing && onSelect(conversation.id)}
onKeyDown={(event) => {
if (!isEditing && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onSelect(conversation.id);
}
}}
className={`group flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
isActive
? isDark
? 'border-cyan-400/40 bg-cyan-500/10 text-slate-50 shadow-sm'
: 'border-red-200 bg-red-50/70 text-gray-900 shadow-sm'
: isDark
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-white hover:text-gray-900'
}`}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
isActive
? isDark
? 'bg-cyan-500/20 text-cyan-200'
: 'bg-white text-red-500 ring-1 ring-red-100'
: isDark
? 'bg-slate-800 text-slate-400'
: 'bg-gray-100 text-gray-500'
}`}
>
<MessageSquareText className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
{isEditing ? (
<input
ref={inputRef}
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
onBlur={commitEdit}
className={`w-full border-b bg-transparent text-sm font-medium outline-none ${
isDark ? 'border-cyan-400/60 text-white' : 'border-red-300 text-gray-900'
}`}
/>
) : (
<div className="truncate text-sm font-medium">{conversation.title || UNTITLED}</div>
)}
</div>
{isEditing ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
commitEdit();
}}
title="保存"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isDark
? 'text-emerald-300 hover:bg-emerald-500/15'
: 'text-emerald-600 hover:bg-emerald-50'
}`}
>
<Check className="h-4 w-4" />
</button>
) : (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
startEdit(conversation);
}}
title="重命名"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleDelete(conversation);
}}
title="删除对话"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} ${
isDark
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
);
})
)}
</div>
</>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center gap-3 px-2 py-4">
<button
type="button"
onClick={handleCollapsedNew}
title="新建对话"
className={`${railButtonClass(false)} sixstep-fade-up`}
>
<Plus className="h-5 w-5" />
</button>
<div className="min-h-0 w-full flex-1 overflow-y-auto">
<div className="flex flex-col items-center gap-2">
{conversations.map((conversation, index) => (
<button
key={conversation.id}
type="button"
onClick={() => handleCollapsedSelect(conversation.id)}
title={conversation.title || UNTITLED}
className={`${railButtonClass(activeId === conversation.id)} transition-all duration-300 ease-out`}
style={{ transitionDelay: `${Math.min(index, 6) * 25}ms` }}
>
<MessageSquareText className="h-4 w-4" />
</button>
))}
</div>
</div>
</div>
)}
</aside>
);
};
export default SixStepChatHistory;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,660 @@
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;
@@ -0,0 +1,653 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Compass,
Crosshair,
Download,
Megaphone,
Minus,
Plus,
RotateCcw,
ScrollText,
Target,
TrendingUp,
UserCheck,
X,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { ExpertCard, SixStepMessage } from './types';
import {
CURRENT_PAIR_BY_STAGE,
EDGE_COLOR,
EDGE_LABEL,
STATUS_TITLE,
VARIANT_LABEL,
buildEdgePath,
buildLayout,
type LayoutEdge,
type LayoutNode,
} from './flowLayout';
interface SixStepFlowFullscreenProps {
message?: SixStepMessage;
onClose: () => void;
}
const SCALE_MIN = 0.2;
const SCALE_MAX = 3;
const buildExpertRootDetail = (expert: ExpertCard): string => {
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
return segments.join(' · ') || '已确认专家';
};
const VARIANT_TOKEN: Record<LayoutNode['variant'], {
dark: string; light: string;
icon: React.ComponentType<{ className?: string }>;
iconCls: { dark: string; light: string };
}> = {
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300', icon: Bot, iconCls: { dark: 'bg-slate-500/30 text-slate-200', light: 'bg-slate-100 text-slate-600' } },
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300', icon: Target, iconCls: { dark: 'bg-violet-500/30 text-violet-200', light: 'bg-orange-100 text-orange-600' } },
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300', icon: UserCheck, iconCls: { dark: 'bg-cyan-500/30 text-cyan-200', light: 'bg-red-100 text-red-600' } },
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300', icon: TrendingUp,iconCls: { dark: 'bg-emerald-500/30 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' } },
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300', icon: Zap, iconCls: { dark: 'bg-teal-500/30 text-teal-200', light: 'bg-teal-100 text-teal-700' } },
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300', icon: ScrollText,iconCls: { dark: 'bg-amber-500/30 text-amber-200', light: 'bg-amber-100 text-amber-600' } },
};
const MINIMAP_VARIANT_FILL: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: '#475569', light: '#cbd5e1' },
purpose: { dark: '#7c3aed', light: '#fb923c' },
actor: { dark: '#06b6d4', light: '#ef4444' },
behavior: { dark: '#10b981', light: '#10b981' },
driver: { dark: '#0d9488', light: '#0d9488' },
narrative: { dark: '#f59e0b', light: '#f59e0b' },
};
const DetailNode: React.FC<{
node: LayoutNode;
isDark: boolean;
collapsible: boolean;
collapsed: boolean;
onToggleCollapse?: (nodeId: string) => void;
entering: boolean;
exiting: boolean;
}> = ({ node, isDark, collapsible, collapsed, onToggleCollapse, entering, exiting }) => {
const token = VARIANT_TOKEN[node.variant];
const Icon = token.icon;
const grad = isDark ? token.dark : token.light;
const canToggle = collapsible && Boolean(onToggleCollapse);
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
if (!canToggle) return;
e.stopPropagation();
onToggleCollapse?.(node.id);
};
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
if (canToggle) e.stopPropagation();
};
return (
<g
onMouseDown={handleMouseDown}
onClick={handleClick}
style={{
cursor: canToggle ? 'pointer' : undefined,
opacity: exiting ? 0 : 1,
transform: entering || exiting ? 'translateY(8px)' : 'translateY(0)',
transformOrigin: `${node.x + node.width / 2}px ${node.y + node.height / 2}px`,
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease, transform ${COLLAPSE_TRANSITION_MS}ms ease`,
}}
>
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm p-2.5 shadow-sm overflow-hidden`}>
{collapsible && (
<div
className={`absolute right-2 top-2 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
}`}
title={collapsed ? '展开子节点' : '收起子节点'}
>
{collapsed ? '+' : '-'}
</div>
)}
<div className="flex items-center gap-1.5 mb-1">
<div className={`w-5 h-5 rounded-md flex items-center justify-center ${token.iconCls[isDark ? 'dark' : 'light']}`}>
<Icon className="w-3 h-3" />
</div>
<span className={`text-[10px] uppercase tracking-wider font-semibold ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
{VARIANT_LABEL[node.variant]}
</span>
</div>
<div className={`text-xs font-semibold leading-tight line-clamp-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{node.title}
</div>
<div className={`text-[11px] leading-snug line-clamp-2 mt-0.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
{node.detail || '—'}
</div>
</div>
</foreignObject>
</g>
);
};
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
<button
type="button"
title={title}
onClick={onClick}
className={`sixstep-toolbar-btn ${
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{children}
</button>
);
interface MinimapProps {
layout: { nodes: LayoutNode[]; width: number; height: number };
transform: { tx: number; ty: number; scale: number };
viewportSize: { width: number; height: number };
onJump: (worldX: number, worldY: number) => void;
isDark: boolean;
}
const MINIMAP_W = 220;
const MINIMAP_H = 150;
const COLLAPSE_TRANSITION_MS = 240;
const Minimap: React.FC<MinimapProps> = ({ layout, transform, viewportSize, onJump, isDark }) => {
const ratio = layout.width > 0 && layout.height > 0
? Math.min((MINIMAP_W - 10) / layout.width, (MINIMAP_H - 10) / layout.height)
: 0.1;
const renderW = layout.width * ratio;
const renderH = layout.height * ratio;
const offsetX = (MINIMAP_W - renderW) / 2;
const offsetY = (MINIMAP_H - renderH) / 2;
// Visible area (in world coords): from world (-tx/scale, -ty/scale) with size viewportW/scale × viewportH/scale
const viewW = viewportSize.width / transform.scale;
const viewH = viewportSize.height / transform.scale;
const viewX = -transform.tx / transform.scale;
const viewY = -transform.ty / transform.scale;
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = e.clientX - rect.left - offsetX;
const my = e.clientY - rect.top - offsetY;
const wx = mx / ratio;
const wy = my / ratio;
onJump(wx, wy);
};
return (
<div className={`rounded-xl overflow-hidden border shadow-lg backdrop-blur-md ${
isDark ? 'bg-slate-900/80 border-slate-700/70' : 'bg-white/90 border-gray-200'
}`}>
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wider border-b font-medium ${
isDark ? 'text-slate-400 border-slate-700/50' : 'text-gray-500 border-gray-100'
}`}>
缩略图
</div>
<svg
width={MINIMAP_W}
height={MINIMAP_H}
onClick={handleClick}
style={{ cursor: 'pointer' }}
>
<rect width={MINIMAP_W} height={MINIMAP_H} fill={isDark ? '#0f172a' : '#f8fafc'} />
<g transform={`translate(${offsetX}, ${offsetY}) scale(${ratio})`}>
{layout.nodes.map((n) => (
<rect
key={n.id}
x={n.x}
y={n.y}
width={n.width}
height={n.height}
rx={6}
fill={MINIMAP_VARIANT_FILL[n.variant][isDark ? 'dark' : 'light']}
opacity={0.75}
/>
))}
</g>
<rect
className="sixstep-minimap-rect"
x={offsetX + viewX * ratio}
y={offsetY + viewY * ratio}
width={Math.max(6, viewW * ratio)}
height={Math.max(6, viewH * ratio)}
rx={3}
/>
</svg>
</div>
);
};
const SixStepFlowFullscreen: React.FC<SixStepFlowFullscreenProps> = ({ message, onClose }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const purposes = message?.stages?.purpose?.cards ?? [];
const actors = message?.stages?.actor?.cards ?? [];
const behaviors = message?.stages?.behavior?.cards ?? [];
const narratives = message?.stages?.narrative?.cards ?? [];
const selectedExpert = message?.selectedExpert;
const layout = useMemo(
() => buildLayout(purposes, actors, behaviors, narratives, {
rootTitle: selectedExpert?.name,
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
}),
[purposes, actors, behaviors, narratives, selectedExpert],
);
const currentSub = message?.currentSubStep;
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
const generating = Boolean(currentSub);
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
const [isPanning, setIsPanning] = useState(false);
const [viewportSize, setViewportSize] = useState({ width: 800, height: 600 });
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
const [displayedNodeIds, setDisplayedNodeIds] = useState<Set<string>>(new Set());
const [enteringNodeIds, setEnteringNodeIds] = useState<Set<string>>(new Set());
const [exitingNodeIds, setExitingNodeIds] = useState<Set<string>>(new Set());
const [displayedEdgeIds, setDisplayedEdgeIds] = useState<Set<string>>(new Set());
const [exitingEdgeIds, setExitingEdgeIds] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const transitionTimerRef = useRef<number | null>(null);
const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
const targetVisibleNodeSet = useMemo(() => {
const visible = new Set<string>();
layout.nodes.forEach((node) => {
let parentId = node.parent;
let hidden = false;
while (parentId) {
if (collapsedNodeIds.has(parentId)) {
hidden = true;
break;
}
parentId = nodeMap.get(parentId)?.parent;
}
if (!hidden) visible.add(node.id);
});
return visible;
}, [collapsedNodeIds, layout.nodes, nodeMap]);
const targetVisibleEdgeSet = useMemo(() => {
const visible = new Set<string>();
layout.edges.forEach((edge) => {
if (targetVisibleNodeSet.has(edge.fromId) && targetVisibleNodeSet.has(edge.toId)) {
visible.add(edge.id);
}
});
return visible;
}, [layout.edges, targetVisibleNodeSet]);
const renderedNodeSet = displayedNodeIds.size > 0 ? displayedNodeIds : targetVisibleNodeSet;
const renderedEdgeSet = displayedEdgeIds.size > 0 ? displayedEdgeIds : targetVisibleEdgeSet;
const renderedNodes = useMemo(
() => layout.nodes.filter((node) => renderedNodeSet.has(node.id)),
[layout.nodes, renderedNodeSet],
);
const renderedEdges = useMemo(
() => layout.edges.filter((edge) => renderedEdgeSet.has(edge.id)),
[layout.edges, renderedEdgeSet],
);
const toggleNodeCollapse = (nodeId: string) => {
const node = nodeMap.get(nodeId);
if (!node || node.children.length === 0) return;
setCollapsedNodeIds((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
return next;
});
};
useEffect(() => {
const update = () => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setViewportSize({ width: rect.width, height: rect.height });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const delta = -e.deltaY * 0.0015;
setTransform((prev) => {
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
const ratio = nextScale / prev.scale;
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
});
};
container.addEventListener('wheel', onWheel, { passive: false });
return () => container.removeEventListener('wheel', onWheel);
}, []);
const handlePanStart = (e: React.MouseEvent) => {
if (e.button !== 0) return;
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
setIsPanning(true);
};
useEffect(() => {
if (!isPanning) return;
const move = (e: MouseEvent) => {
const start = panStart.current;
if (!start) return;
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
};
const up = () => {
panStart.current = null;
setIsPanning(false);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
}, [isPanning]);
const fitToView = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const scale = Math.min((rect.width - 80) / Math.max(layout.width, 1), (rect.height - 80) / Math.max(layout.height, 1), 1);
const tx = (rect.width - layout.width * scale) / 2;
const ty = (rect.height - layout.height * scale) / 2;
setTransform({ tx, ty, scale });
};
const centerView = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: (rect.width - layout.width * prev.scale) / 2,
ty: (rect.height - layout.height * prev.scale) / 2,
}));
};
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
const fitAppliedRef = useRef(false);
useEffect(() => {
if (!fitAppliedRef.current && viewportSize.width > 0 && layout.nodes.length > 1) {
fitToView();
fitAppliedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewportSize.width, layout.nodes.length]);
const handleMinimapJump = (wx: number, wy: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: rect.width / 2 - wx * prev.scale,
ty: rect.height / 2 - wy * prev.scale,
}));
};
const handleDownload = () => {
const svg = svgRef.current;
if (!svg) return;
const cloned = svg.cloneNode(true) as SVGSVGElement;
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
cloned.setAttribute('width', String(layout.width));
cloned.setAttribute('height', String(layout.height));
cloned.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`);
const inner = cloned.querySelector('g');
if (inner) inner.setAttribute('transform', '');
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(cloned);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `6步法推演-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
const validIds = new Set(layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
setCollapsedNodeIds((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (validIds.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [layout.nodes]);
useEffect(() => {
if (transitionTimerRef.current) {
window.clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
const prevNodes = displayedNodeIds;
const nextNodes = targetVisibleNodeSet;
const prevEdges = displayedEdgeIds;
const nextEdges = targetVisibleEdgeSet;
const enteringNodes = new Set([...nextNodes].filter((id) => !prevNodes.has(id)));
const leavingNodes = new Set([...prevNodes].filter((id) => !nextNodes.has(id)));
const leavingEdges = new Set([...prevEdges].filter((id) => !nextEdges.has(id)));
const mergedNodes = new Set([...prevNodes, ...nextNodes]);
const mergedEdges = new Set([...prevEdges, ...nextEdges]);
setEnteringNodeIds(enteringNodes);
setExitingNodeIds(leavingNodes);
setExitingEdgeIds(leavingEdges);
setDisplayedNodeIds(mergedNodes);
setDisplayedEdgeIds(mergedEdges);
transitionTimerRef.current = window.setTimeout(() => {
setDisplayedNodeIds(new Set(nextNodes));
setDisplayedEdgeIds(new Set(nextEdges));
setEnteringNodeIds(new Set());
setExitingNodeIds(new Set());
setExitingEdgeIds(new Set());
transitionTimerRef.current = null;
}, COLLAPSE_TRANSITION_MS);
}, [targetVisibleNodeSet, targetVisibleEdgeSet]);
useEffect(() => {
return () => {
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
};
}, []);
return (
<div className={`fixed inset-0 z-50 flex flex-col ${isDark ? 'bg-slate-950/95' : 'bg-white/95'} backdrop-blur-sm`}>
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/70' : 'border-red-100 bg-white'
}`}>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
} ${generating ? 'sixstep-pulse' : ''}`}>
<Compass className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className={`text-base font-semibold ${
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
}`}>
{statusTitle}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
全屏视图 · 完整内容展示
</div>
</div>
</div>
<div className="flex items-center gap-1">
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="下载流程图(SVG)" onClick={handleDownload} isDark={isDark}><Download className="w-4 h-4" /></ToolbarButton>
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.round(transform.scale * 100)}%
</span>
<ToolbarButton title="关闭全屏 (Esc)" onClick={onClose} isDark={isDark}>
<X className="w-4 h-4" />
</ToolbarButton>
</div>
</div>
<div className={`flex items-center gap-3 px-5 py-2 border-b text-[11px] flex-wrap ${
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/40' : 'border-red-100 text-gray-500 bg-white'
}`}>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
const active = activePair === pair;
return (
<span key={pair} className="inline-flex items-center gap-1.5">
<span className={`inline-block w-6 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
</span>
);
})}
</div>
<div
ref={containerRef}
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
onMouseDown={handlePanStart}
>
{renderedNodes.length <= 1 ? (
<div className={`h-full flex flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
<Megaphone className="w-12 h-12 opacity-40 mb-3" />
<div className={`text-base font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
<div className="text-sm mt-1">推演产出将以流程图形式呈现</div>
</div>
) : (
<svg ref={svgRef} width="100%" height="100%" className="block select-none">
<defs>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
return (
<marker
key={pair}
id={`sixstep-fs-arrow-${pair}`}
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
</marker>
);
})}
</defs>
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
{renderedEdges.map((edge) => {
const from = nodeMap.get(edge.fromId);
const to = nodeMap.get(edge.toId);
if (!from || !to) return null;
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
const isActive = activePair === edge.pair;
const exiting = exitingEdgeIds.has(edge.id);
return (
<path
key={edge.id}
d={buildEdgePath(from, to)}
fill="none"
stroke={color}
strokeWidth={isActive ? 2.4 : 1.8}
strokeOpacity={exiting ? 0 : isActive ? 1 : 0.75}
className={isActive ? 'sixstep-ant-line' : ''}
markerEnd={`url(#sixstep-fs-arrow-${edge.pair})`}
style={{
opacity: exiting ? 0 : 1,
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease`,
}}
/>
);
})}
{renderedNodes.map((node) => (
<DetailNode
key={node.id}
node={node}
isDark={isDark}
collapsible={node.children.length > 0}
collapsed={collapsedNodeIds.has(node.id)}
onToggleCollapse={toggleNodeCollapse}
entering={enteringNodeIds.has(node.id)}
exiting={exitingNodeIds.has(node.id)}
/>
))}
</g>
</svg>
)}
{targetVisibleNodeSet.size > 1 && (
<div className="absolute bottom-4 right-4">
<Minimap
layout={{
nodes: layout.nodes.filter((node) => targetVisibleNodeSet.has(node.id)),
width: layout.width,
height: layout.height,
}}
transform={transform}
viewportSize={viewportSize}
onJump={handleMinimapJump}
isDark={isDark}
/>
</div>
)}
<div className={`absolute bottom-4 left-4 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
点击父节点可收起/展开子节点
</div>
</div>
</div>
);
};
export default SixStepFlowFullscreen;
@@ -0,0 +1,520 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Compass,
Crosshair,
Maximize2,
Megaphone,
Minus,
Plus,
RotateCcw,
ScrollText,
Target,
TrendingUp,
UserCheck,
X,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { ExpertCard, SixStepMessage } from './types';
import {
CURRENT_PAIR_BY_STAGE,
EDGE_COLOR,
EDGE_LABEL,
STATUS_TITLE,
VARIANT_ABBREV,
VARIANT_LABEL,
buildEdgePath,
buildLayout,
type LayoutEdge,
type LayoutNode,
} from './flowLayout';
interface SixStepFlowSandboxProps {
message?: SixStepMessage;
width: number;
onResizeStart: (e: React.MouseEvent) => void;
onClose: () => void;
onOpenFullscreen: () => void;
}
const COMPACT_OPTIONS = {
nodeWidth: 120,
nodeHeight: 60,
levelGap: 80,
nodeGap: 24,
paddingX: 32,
paddingY: 28,
};
const VARIANT_GRADIENT: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300' },
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300' },
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300' },
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300' },
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300' },
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300' },
};
const VARIANT_ICON: Record<LayoutNode['variant'], React.ComponentType<{ className?: string }>> = {
agent: Bot,
purpose: Target,
actor: UserCheck,
behavior: TrendingUp,
driver: Zap,
narrative: ScrollText,
};
// Colored icon wrapper per variant (dark / light)
const VARIANT_ICON_CLS: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: 'bg-slate-600/60 text-slate-200', light: 'bg-slate-100 text-slate-600' },
purpose: { dark: 'bg-violet-500/25 text-violet-200', light: 'bg-orange-100 text-orange-600' },
actor: { dark: 'bg-cyan-500/25 text-cyan-200', light: 'bg-red-100 text-red-600' },
behavior: { dark: 'bg-emerald-500/25 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' },
driver: { dark: 'bg-teal-500/25 text-teal-200', light: 'bg-teal-100 text-teal-700' },
narrative: { dark: 'bg-amber-500/25 text-amber-200', light: 'bg-amber-100 text-amber-600' },
};
const SCALE_MIN = 0.3;
const SCALE_MAX = 2.5;
const buildExpertRootDetail = (expert: ExpertCard): string => {
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
return segments.join(' · ') || '已确认专家';
};
const CompactNode: React.FC<{
node: LayoutNode;
isDark: boolean;
appearing: boolean;
collapsible: boolean;
collapsed: boolean;
onToggleCollapse?: (nodeId: string) => void;
}> = ({ node, isDark, appearing, collapsible, collapsed, onToggleCollapse }) => {
const Icon = VARIANT_ICON[node.variant];
const grad = VARIANT_GRADIENT[node.variant][isDark ? 'dark' : 'light'];
const canToggle = collapsible && Boolean(onToggleCollapse);
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
if (!canToggle) return;
e.stopPropagation();
onToggleCollapse(node.id);
};
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
if (canToggle) e.stopPropagation();
};
return (
<g
className={appearing ? 'sixstep-node-pop' : ''}
onMouseDown={handleMouseDown}
onClick={handleClick}
style={canToggle ? { cursor: 'pointer' } : undefined}
>
{appearing && (
<circle
cx={node.x + node.width / 2}
cy={node.y + node.height / 2}
r={0}
stroke={isDark ? '#22d3ee' : '#ef4444'}
className="sixstep-ring-burst"
/>
)}
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm flex items-center justify-center gap-1.5 px-2 shadow-sm`}>
{collapsible && (
<div
className={`absolute right-1.5 top-1.5 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
}`}
title={collapsed ? '展开子节点' : '收起子节点'}
>
{collapsed ? '+' : '-'}
</div>
)}
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${VARIANT_ICON_CLS[node.variant][isDark ? 'dark' : 'light']}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<span className={`text-sm font-bold tracking-wider ${isDark ? 'text-white' : 'text-slate-800'}`}>
{VARIANT_ABBREV[node.variant]}
</span>
</div>
</foreignObject>
</g>
);
};
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
<button
type="button"
title={title}
onClick={onClick}
className={`sixstep-toolbar-btn ${
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{children}
</button>
);
const SixStepFlowSandbox: React.FC<SixStepFlowSandboxProps> = ({ message, width, onResizeStart, onClose, onOpenFullscreen }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const purposes = message?.stages?.purpose?.cards ?? [];
const actors = message?.stages?.actor?.cards ?? [];
const behaviors = message?.stages?.behavior?.cards ?? [];
const narratives = message?.stages?.narrative?.cards ?? [];
const selectedExpert = message?.selectedExpert;
const layout = useMemo(
() => buildLayout(purposes, actors, behaviors, narratives, {
...COMPACT_OPTIONS,
rootTitle: selectedExpert?.name,
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
}),
[purposes, actors, behaviors, narratives, selectedExpert],
);
const currentSub = message?.currentSubStep;
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
const generating = Boolean(currentSub);
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
const [isPanning, setIsPanning] = useState(false);
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
// Track newly appearing nodes for animation
const seenIdsRef = useRef<Set<string>>(new Set());
const [appearingIds, setAppearingIds] = useState<Set<string>>(new Set());
const nodeMap = useMemo(() => {
return new Map(layout.nodes.map((node) => [node.id, node]));
}, [layout.nodes]);
const visibleNodeSet = useMemo(() => {
const visible = new Set<string>();
layout.nodes.forEach((node) => {
let parentId = node.parent;
let hidden = false;
while (parentId) {
if (collapsedNodeIds.has(parentId)) {
hidden = true;
break;
}
parentId = nodeMap.get(parentId)?.parent;
}
if (!hidden) visible.add(node.id);
});
return visible;
}, [collapsedNodeIds, layout.nodes, nodeMap]);
const visibleNodes = useMemo(
() => layout.nodes.filter((node) => visibleNodeSet.has(node.id)),
[layout.nodes, visibleNodeSet],
);
const visibleEdges = useMemo(
() => layout.edges.filter((edge) => visibleNodeSet.has(edge.fromId) && visibleNodeSet.has(edge.toId)),
[layout.edges, visibleNodeSet],
);
const toggleNodeCollapse = (nodeId: string) => {
const targetNode = nodeMap.get(nodeId);
if (!targetNode || targetNode.children.length === 0) return;
setCollapsedNodeIds((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
return next;
});
};
useEffect(() => {
const validIds = new Set(
layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id),
);
setCollapsedNodeIds((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (validIds.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [layout.nodes]);
useEffect(() => {
const newIds: string[] = [];
visibleNodes.forEach((n) => {
if (!seenIdsRef.current.has(n.id)) {
seenIdsRef.current.add(n.id);
newIds.push(n.id);
}
});
if (newIds.length > 0) {
setAppearingIds((prev) => {
const next = new Set(prev);
newIds.forEach((id) => next.add(id));
return next;
});
const timer = window.setTimeout(() => {
setAppearingIds((prev) => {
const next = new Set(prev);
newIds.forEach((id) => next.delete(id));
return next;
});
}, 700);
return () => window.clearTimeout(timer);
}
}, [visibleNodes]);
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const delta = -e.deltaY * 0.0015;
setTransform((prev) => {
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
const ratio = nextScale / prev.scale;
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
});
};
container.addEventListener('wheel', onWheel, { passive: false });
return () => container.removeEventListener('wheel', onWheel);
}, []);
const handlePanStart = (e: React.MouseEvent) => {
if (e.button !== 0) return;
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
setIsPanning(true);
};
useEffect(() => {
if (!isPanning) return;
const move = (e: MouseEvent) => {
const start = panStart.current;
if (!start) return;
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
};
const up = () => {
panStart.current = null;
setIsPanning(false);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
}, [isPanning]);
const fitToView = () => {
const container = containerRef.current;
if (!container || layout.width <= 0 || layout.height <= 0) {
setTransform({ tx: 0, ty: 0, scale: 1 });
return;
}
const rect = container.getBoundingClientRect();
const scale = Math.min((rect.width - 40) / layout.width, (rect.height - 40) / layout.height, 1);
const tx = (rect.width - layout.width * scale) / 2;
const ty = (rect.height - layout.height * scale) / 2;
setTransform({ tx, ty, scale });
};
const centerView = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: (rect.width - layout.width * prev.scale) / 2,
ty: (rect.height - layout.height * prev.scale) / 2,
}));
};
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
// Auto-fit on first render with content
const fitAppliedRef = useRef(false);
useEffect(() => {
if (!fitAppliedRef.current && layout.nodes.length > 1) {
fitToView();
fitAppliedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout.nodes.length]);
return (
<div
className={`relative h-full flex flex-col border-l ${
isDark ? 'border-slate-700/60 bg-slate-900/60' : 'border-red-100 bg-white'
}`}
style={{ width }}
>
<div className="sixstep-resize-handle" onMouseDown={onResizeStart} />
<div className={`flex items-center justify-between gap-3 px-4 py-3 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-red-100 bg-gradient-to-r from-red-50/60 to-orange-50/40'
}`}>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-8 h-8 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
} ${generating ? 'sixstep-pulse' : ''}`}>
<Compass className="w-4 h-4" />
</div>
<div className="min-w-0">
<div className={`text-sm font-semibold truncate ${
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
}`}>
{statusTitle}
</div>
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
简化视图 · 节点缩写显示
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className={`p-1.5 rounded-lg transition-colors ${
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
}`}
title="关闭沙箱"
>
<X className="w-4 h-4" />
</button>
</div>
<div className={`flex items-center gap-1 px-3 py-2 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'
}`}>
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.round(transform.scale * 100)}%
</span>
<div className="flex-1" />
<ToolbarButton title="全屏查看完整流程图" onClick={onOpenFullscreen} isDark={isDark}>
<Maximize2 className="w-4 h-4" />
</ToolbarButton>
</div>
<div className={`flex items-center gap-3 px-3 py-1.5 border-b text-[11px] flex-wrap ${
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/30' : 'border-red-100 text-gray-500 bg-white'
}`}>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
const active = activePair === pair;
return (
<span key={pair} className="inline-flex items-center gap-1.5">
<span className={`inline-block w-5 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
</span>
);
})}
</div>
<div
ref={containerRef}
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
onMouseDown={handlePanStart}
>
{visibleNodes.length <= 1 ? (
<div className={`h-full flex flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
<Megaphone className="w-10 h-10 opacity-40 mb-3" />
<div className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
<div className="text-xs mt-1">推演产出将以流程图形式呈现</div>
</div>
) : (
<svg
width="100%"
height="100%"
className="block select-none"
>
<defs>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
return (
<marker
key={pair}
id={`sixstep-sandbox-arrow-${pair}`}
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
</marker>
);
})}
</defs>
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
{visibleEdges.map((edge) => {
const from = nodeMap.get(edge.fromId);
const to = nodeMap.get(edge.toId);
if (!from || !to) return null;
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
const isActive = activePair === edge.pair;
return (
<path
key={edge.id}
d={buildEdgePath(from, to)}
fill="none"
stroke={color}
strokeWidth={isActive ? 2.2 : 1.6}
strokeOpacity={isActive ? 1 : 0.7}
className={isActive ? 'sixstep-ant-line' : ''}
markerEnd={`url(#sixstep-sandbox-arrow-${edge.pair})`}
/>
);
})}
{visibleNodes.map((node) => (
<CompactNode
key={node.id}
node={node}
isDark={isDark}
appearing={appearingIds.has(node.id)}
collapsible={node.children.length > 0}
collapsed={collapsedNodeIds.has(node.id)}
onToggleCollapse={toggleNodeCollapse}
/>
))}
</g>
</svg>
)}
<div className={`absolute bottom-3 left-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
{VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
</div>
<div className={`absolute bottom-3 right-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
点击父节点可收起/展开子节点
</div>
</div>
</div>
);
};
export default SixStepFlowSandbox;
@@ -0,0 +1,314 @@
import React from 'react';
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Circle,
Database,
FileCheck2,
Loader2,
Megaphone,
Sparkles,
Target,
Users,
Workflow,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { MainStepKey, StepStatus, SubStepKey } from './types';
interface SixStepProgressProps {
mainStatus: Record<MainStepKey, StepStatus>;
subStatus: Record<SubStepKey, StepStatus>;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
interface StepItem<T extends string> {
key: T;
title: string;
desc: string;
icon: React.ComponentType<{ className?: string }>;
color: { dark: string; light: string };
}
const MAIN_STEPS: StepItem<MainStepKey>[] = [
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
];
const SUB_STEPS: StepItem<SubStepKey>[] = [
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone,color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
];
function dotColor(status: StepStatus, isDark: boolean) {
if (status === 'done') return isDark ? 'bg-emerald-400' : 'bg-emerald-500';
if (status === 'running') return isDark ? 'bg-cyan-400 sixstep-pulse' : 'bg-red-500 sixstep-pulse';
return isDark ? 'bg-slate-600' : 'bg-gray-300';
}
function StatusIcon({ status, isDark }: { status: StepStatus; isDark: boolean }) {
if (status === 'done') return <CheckCircle2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-500'}`} />;
if (status === 'running') return <Loader2 className={`w-4 h-4 sixstep-spin-soft ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />;
return <Circle className={`w-3.5 h-3.5 ${isDark ? 'text-slate-500' : 'text-gray-300'}`} />;
}
function cardBorder(status: StepStatus, isDark: boolean) {
if (status === 'running') return isDark
? 'border-cyan-400/40 bg-cyan-500/5 shadow-[0_0_0_1px_rgba(34,211,238,0.15)]'
: 'border-red-300 bg-red-50/60 shadow-sm';
return isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-gray-200 bg-white';
}
function connectorColor(status: StepStatus, isDark: boolean) {
return status === 'done'
? isDark ? 'bg-emerald-400/40' : 'bg-emerald-300'
: isDark ? 'bg-slate-700/60' : 'bg-gray-200';
}
// ── Collapsed view ────────────────────────────────────────────────────────────
const CollapsedView: React.FC<{
mainStatus: Record<MainStepKey, StepStatus>;
subStatus: Record<SubStepKey, StepStatus>;
isDark: boolean;
onExpand: () => void;
}> = ({ mainStatus, subStatus, isDark, onExpand }) => {
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
return (
<div className={`h-full flex flex-col items-center py-3 gap-3 border-r ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'
}`}>
<button
type="button"
onClick={onExpand}
title="展开进度面板"
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-colors ${
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
}`}
>
<ChevronRight className="w-4 h-4" />
</button>
<div className="flex flex-col items-center gap-2 flex-1">
{MAIN_STEPS.map((step) => {
const status = mainStatus[step.key];
const Icon = step.icon;
const clr = step.color[isDark ? 'dark' : 'light'];
return (
<div key={step.key} className="relative flex flex-col items-center gap-1" title={step.title}>
<div className={`w-9 h-9 rounded-xl border flex items-center justify-center ${clr} ${
status === 'running' ? 'sixstep-pulse' : ''
}`}>
{status === 'running'
? <Loader2 className="w-4 h-4 sixstep-spin-soft" />
: <Icon className="w-4 h-4" />
}
</div>
<span className={`w-2 h-2 rounded-full ${dotColor(status, isDark)}`} />
{step.key === 'sixstep' && sixstepUnlocked && (
<div className="flex flex-col items-center gap-1 mt-1">
{SUB_STEPS.map((sub) => {
const ss = subStatus[sub.key];
const SubIcon = sub.icon;
const sc = sub.color[isDark ? 'dark' : 'light'];
return (
<div key={sub.key} title={sub.title} className={`w-7 h-7 rounded-lg border flex items-center justify-center ${sc} ${
ss === 'running' ? 'sixstep-pulse' : ''
}`}>
{ss === 'running'
? <Loader2 className="w-3 h-3 sixstep-spin-soft" />
: <SubIcon className="w-3 h-3" />
}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
// ── Expanded view ─────────────────────────────────────────────────────────────
const SixStepProgress: React.FC<SixStepProgressProps> = ({
mainStatus,
subStatus,
collapsed = false,
onToggleCollapse,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
const sixstepRunning = mainStatus.sixstep === 'running';
if (collapsed) {
return (
<CollapsedView
mainStatus={mainStatus}
subStatus={subStatus}
isDark={isDark}
onExpand={() => onToggleCollapse?.()}
/>
);
}
return (
<div className={`h-full flex flex-col overflow-hidden border-r ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'}`}>
{/* Header */}
<div className={`px-4 py-3 border-b flex items-center gap-2 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>生成进度</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>实时跟踪推演阶段</div>
</div>
<button
type="button"
onClick={onToggleCollapse}
title="收起进度面板"
className={`w-8 h-8 rounded-xl flex items-center justify-center transition-colors shrink-0 ${
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
}`}
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
{/* Steps */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{MAIN_STEPS.map((step, idx) => {
const status = mainStatus[step.key];
const Icon = step.icon;
const clr = step.color[isDark ? 'dark' : 'light'];
const showSubBlock = step.key === 'sixstep' && sixstepUnlocked;
return (
<div key={step.key} className="relative">
{idx < MAIN_STEPS.length - 1 && (
<div className={`absolute left-[22px] top-12 bottom-[-12px] w-px ${connectorColor(status, isDark)}`} />
)}
<div className={`relative rounded-2xl border p-3 transition-all ${cardBorder(status, isDark)}`}>
<div className="flex items-start gap-3">
<div className={`w-11 h-11 rounded-2xl border flex items-center justify-center shrink-0 transition-colors ${clr} ${
status === 'running' ? 'sixstep-pulse' : ''
}`}>
{status === 'running'
? <Loader2 className="w-5 h-5 sixstep-spin-soft" />
: <Icon className="w-5 h-5" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className={`text-sm font-semibold truncate ${
status === 'running'
? isDark ? 'text-white' : 'text-gray-900'
: status === 'done'
? isDark ? 'text-slate-100' : 'text-gray-800'
: isDark ? 'text-slate-300' : 'text-gray-600'
}`}>{step.title}</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] flex items-center gap-1 ${
status === 'done'
? isDark ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-400/30' : 'bg-emerald-50 text-emerald-600 border border-emerald-200'
: status === 'running'
? isDark ? 'bg-cyan-500/20 text-cyan-200 border border-cyan-400/40' : 'bg-red-50 text-red-600 border border-red-200'
: isDark ? 'bg-slate-700/40 text-slate-400 border border-slate-600/40' : 'bg-gray-100 text-gray-500 border border-gray-200'
}`}>
<StatusIcon status={status} isDark={isDark} />
<span>{status === 'done' ? '已完成' : status === 'running' ? '生成中' : '待开始'}</span>
</span>
</div>
<div className={`text-xs mt-1 ${
status === 'running'
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-400' : 'text-gray-500'
}`}>{step.desc}</div>
</div>
</div>
{/* 6步法子阶段 */}
{showSubBlock && (
<div className="mt-3 pl-1">
<div className={`text-[10px] font-medium tracking-wider uppercase mb-2 ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
6 步法 · 子阶段
</div>
<div className="space-y-2">
{SUB_STEPS.map((sub, subIdx) => {
const ss = subStatus[sub.key];
const SubIcon = sub.icon;
const sc = sub.color[isDark ? 'dark' : 'light'];
return (
<div key={sub.key} className="relative pl-4 sixstep-fade-up">
{subIdx < SUB_STEPS.length - 1 && (
<div className={`absolute left-[7px] top-7 bottom-[-8px] w-px ${connectorColor(ss, isDark)}`} />
)}
<div className={`absolute left-0 top-3 w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center ${
ss === 'running'
? isDark ? 'bg-cyan-500/30 border-cyan-300' : 'bg-red-100 border-red-400'
: ss === 'done'
? isDark ? 'bg-emerald-500/30 border-emerald-300' : 'bg-emerald-100 border-emerald-400'
: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300'
}`} />
<div className={`flex items-start gap-2 rounded-xl border p-2 ${
ss === 'running'
? isDark ? 'border-cyan-400/40 bg-cyan-500/5' : 'border-red-300 bg-red-50/70'
: isDark ? 'border-slate-700/50 bg-slate-800/40' : 'border-gray-200 bg-white'
}`}>
<div className={`w-7 h-7 rounded-lg border flex items-center justify-center shrink-0 ${sc} ${
ss === 'running' ? 'sixstep-pulse' : ''
}`}>
{ss === 'running'
? <Loader2 className="w-3.5 h-3.5 sixstep-spin-soft" />
: <SubIcon className="w-3.5 h-3.5" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className={`text-xs font-semibold truncate ${
ss === 'done' ? isDark ? 'text-slate-100' : 'text-gray-800'
: ss === 'running' ? isDark ? 'text-white' : 'text-gray-900'
: isDark ? 'text-slate-300' : 'text-gray-600'
}`}>{sub.title}</div>
<StatusIcon status={ss} isDark={isDark} />
</div>
<div className={`text-[11px] mt-0.5 ${
ss === 'running'
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-500' : 'text-gray-400'
}`}>{sub.desc}</div>
</div>
</div>
</div>
);
})}
</div>
{sixstepRunning && (
<div className={`mt-2 text-[11px] flex items-center gap-1.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`}>
<Loader2 className="w-3 h-3 sixstep-spin-soft" />
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>推演正在进行...</span>
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
export default SixStepProgress;
@@ -0,0 +1,196 @@
import React from 'react';
import {
CheckCircle2,
Database,
FileCheck2,
Loader2,
Megaphone,
Target,
Users,
Workflow,
Zap,
} from 'lucide-react';
import type { MainStepKey, StepStatus, SubStepKey } from './types';
interface Props {
mainStatus: Record<MainStepKey, StepStatus>;
subStatus: Record<SubStepKey, StepStatus>;
isDark: boolean;
}
interface StepDef<T extends string> {
key: T;
title: string;
desc: string;
icon: React.ComponentType<{ className?: string }>;
color: { dark: string; light: string };
}
const MAIN_STEPS: StepDef<MainStepKey>[] = [
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
];
const SUB_STEPS: StepDef<SubStepKey>[] = [
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone, color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
];
function statusLabel(s: StepStatus) {
return s === 'done' ? '已完成' : s === 'running' ? '生成中' : '待开始';
}
function nodeStateClass(status: StepStatus, isDark: boolean) {
if (status === 'running') {
return `${isDark ? 'ring-2 ring-cyan-400/30' : 'ring-2 ring-red-200'} sixstep-pulse`;
}
if (status === 'done') {
return isDark ? 'ring-1 ring-emerald-400/40' : 'ring-1 ring-emerald-300';
}
return isDark ? 'opacity-55 saturate-50' : 'opacity-70 saturate-50';
}
function connectorClass(status: StepStatus, isDark: boolean) {
if (status === 'done') return isDark ? 'bg-emerald-400/60' : 'bg-emerald-400';
if (status === 'running') return isDark ? 'bg-cyan-400/50' : 'bg-red-300';
return isDark ? 'bg-slate-700/60' : 'bg-gray-200';
}
interface NodeProps {
status: StepStatus;
Icon: React.ComponentType<{ className?: string }>;
color: string;
size?: 'md' | 'sm';
title: string;
desc: string;
isDark: boolean;
}
const StepNode: React.FC<NodeProps> = ({ status, Icon, color, size = 'md', title, desc, isDark }) => {
const wrapSize = size === 'md' ? 'w-9 h-9' : 'w-7 h-7';
const iconSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
const badgeSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
const badgeIcon = size === 'md' ? 'w-3 h-3' : 'w-2.5 h-2.5';
return (
<div className="relative group shrink-0">
<div
className={`${wrapSize} rounded-xl border flex items-center justify-center transition-all ${color} ${nodeStateClass(status, isDark)}`}
>
{status === 'running'
? <Loader2 className={`${iconSize} sixstep-spin-soft`} />
: <Icon className={iconSize} />
}
</div>
{status === 'done' && (
<div
className={`absolute -bottom-0.5 -right-0.5 ${badgeSize} rounded-full flex items-center justify-center shadow-sm ${
isDark ? 'bg-emerald-500 text-slate-900' : 'bg-emerald-500 text-white'
}`}
>
<CheckCircle2 className={badgeIcon} />
</div>
)}
<div
className={`absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1.5 rounded-lg whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg border ${
isDark
? 'bg-slate-900 text-slate-100 border-slate-700'
: 'bg-white text-gray-800 border-gray-200'
}`}
>
<div className="text-xs font-semibold">{title}</div>
<div
className={`text-[11px] ${
status === 'running'
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-400' : 'text-gray-500'
}`}
>
{desc}
</div>
<div
className={`text-[10px] mt-0.5 flex items-center gap-1 ${
status === 'done'
? isDark ? 'text-emerald-300' : 'text-emerald-600'
: status === 'running'
? isDark ? 'text-cyan-300' : 'text-red-500'
: isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
{status === 'running' && <Loader2 className="w-2.5 h-2.5 sixstep-spin-soft" />}
<span>{statusLabel(status)}</span>
</div>
</div>
</div>
);
};
const SixStepProgressHorizontal: React.FC<Props> = ({ mainStatus, subStatus, isDark }) => {
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
return (
<div className="flex items-center gap-2 flex-nowrap py-1">
{MAIN_STEPS.map((step, idx) => {
const status = mainStatus[step.key];
const clr = step.color[isDark ? 'dark' : 'light'];
const isSixStep = step.key === 'sixstep';
const prevStatus = idx > 0 ? mainStatus[MAIN_STEPS[idx - 1].key] : 'pending';
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className={`h-[2px] w-5 rounded shrink-0 transition-colors ${connectorClass(prevStatus, isDark)}`} />
)}
<StepNode
status={status}
Icon={step.icon}
color={clr}
title={step.title}
desc={step.desc}
isDark={isDark}
/>
{isSixStep && sixstepUnlocked && (
<>
<div className={`h-[2px] w-3 rounded shrink-0 transition-colors ${connectorClass(status, isDark)}`} />
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-xl border shrink-0 ${
isDark ? 'bg-slate-900/40 border-slate-700/60' : 'bg-gray-50 border-gray-200'
}`}
>
{SUB_STEPS.map((sub, sidx) => {
const ss = subStatus[sub.key];
const sc = sub.color[isDark ? 'dark' : 'light'];
const prevSub = sidx > 0 ? subStatus[SUB_STEPS[sidx - 1].key] : 'pending';
return (
<React.Fragment key={sub.key}>
{sidx > 0 && (
<div className={`h-[2px] w-2 rounded shrink-0 transition-colors ${connectorClass(prevSub, isDark)}`} />
)}
<StepNode
status={ss}
Icon={sub.icon}
color={sc}
size="sm"
title={sub.title}
desc={sub.desc}
isDark={isDark}
/>
</React.Fragment>
);
})}
</div>
</>
)}
</React.Fragment>
);
})}
</div>
);
};
export default SixStepProgressHorizontal;
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { AlertTriangle, ChevronDown, ChevronUp, XCircle } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { StreamErrorItem } from './types';
interface StreamErrorNoticeProps {
errors: StreamErrorItem[];
}
const StreamErrorNotice: React.FC<StreamErrorNoticeProps> = ({ errors }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [expanded, setExpanded] = useState(false);
if (!errors || errors.length === 0) return null;
const hasErrors = errors.some((e) => e.level === 'error');
const errorCount = errors.filter((e) => e.level === 'error').length;
const warnCount = errors.filter((e) => e.level === 'warning').length;
let summaryText: string;
if (hasErrors && warnCount > 0) {
summaryText = `${errorCount} 个步骤失败,${warnCount} 个能力调用降级`;
} else if (hasErrors) {
summaryText = `${errorCount} 个步骤执行失败`;
} else {
summaryText = `${warnCount} 个能力调用失败,系统已继续执行`;
}
const borderClass = hasErrors
? isDark ? 'border-red-500/40 bg-red-500/10 text-red-200' : 'border-red-200 bg-red-50 text-red-700'
: isDark ? 'border-amber-500/40 bg-amber-500/10 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-700';
const dividerClass = hasErrors
? isDark ? 'border-red-500/20' : 'border-red-200'
: isDark ? 'border-amber-500/20' : 'border-amber-200';
return (
<div className={`rounded-xl border text-xs ${borderClass}`}>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left"
onClick={() => setExpanded((v) => !v)}
>
{hasErrors
? <XCircle className="w-3.5 h-3.5 shrink-0" />
: <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
<span className="flex-1 font-medium">{summaryText}</span>
{expanded
? <ChevronUp className="w-3.5 h-3.5 shrink-0 opacity-50" />
: <ChevronDown className="w-3.5 h-3.5 shrink-0 opacity-50" />}
</button>
{expanded && (
<div className={`border-t px-3 py-2 space-y-2.5 ${dividerClass}`}>
{errors.map((err) => (
<div key={err.errorId}>
<div className="flex items-start gap-1.5">
{err.level === 'error'
? <XCircle className="w-3 h-3 mt-0.5 shrink-0" />
: <AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />}
<span className="leading-snug">{err.message}</span>
</div>
<div className={`mt-0.5 pl-[18px] font-mono text-[10px] opacity-40`}>
错误 ID:{err.errorId}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default StreamErrorNotice;
File diff suppressed because it is too large. Load diff
+826
View File
@@ -0,0 +1,826 @@
# 6 步法推演问答页面 — 开发说明文档
> 所在目录:`src/components/angentchat/`(主页面 `SixStepChatPage.tsx`)
> 路由地址:`/qa-center/sixstep-chat`
> 菜单入口:问答中心 → **6bf**(侧栏文案见 `src/components/Sidebar.tsx`)
> 同模块相关路由:`/qa-center/sixstep-agents`、`/qa-center/sixstep-agent-edit`
> **最后更新(摘要)**:2026-05-12 — 流式 **`error_log`** / **`errors[]`** 与 `StreamErrorNotice`(后端 skill 失败摘要可含异常片段);`buildLayout` 根节点 **`rootTitle` / `rootDetail`**(已选专家);**`SixStepFlowFullscreen`** 父节点 **收起/展开子树**(过渡动画、缩略图随可见节点);**`/observability`** 独立全站路由。
---
## 一、功能概述
「6bf 推演问答」由 `SixStepChatPage` 对接后端 **SixStep MCP Backend**。主链路通过 **`POST /api/orchestrate/stream`** 统一流式返回:后端先做 **意图识别**(`intent` 事件),再分支为 **查找专家**(`expert_*`)、**6 步法编排**(信息准备 → 目的 → 行为体 → 预期行为与驱动因素 → 叙事 → 最终结果)、**追问**(`follow_up`,`delta` 流式 Markdown)或 **澄清**(`clarify`)。支持 **结构化卡片** 与 **Markdown** 输出切换;结构化模式下可打开 **流程图沙箱 / 全屏流程图**(`SixStepFlowSandbox` / `SixStepFlowFullscreen`),展示编排返回的实体关系。
单 MCP 调试仍可直接调用 **`POST /api/agents/{key}/invoke`**(例如 `find_expert`、`create_expert`);6bf 主页面以编排流为主,专家查找也可由流内 `intent` 分支触发。对接细节见同目录 **`INTENT_INTEGRATION.md`**。
页面布局为 **顶栏 + 下方主体**:顶栏含页面标题、`SixStepProgressHorizontal`(横向主/子步骤进度)、以及结构化·Markdown、聊天记录、流程图沙箱等控制;主体为 **可收起的会话列表轨**(`SixStepChatHistory`)、**中间对话区与输入**(`SixStepConversation` + `ChatInputComposer`,含专家匹配与 MCP 选择弹窗)、以及 **可选的右侧沙箱**(宽度可拖拽,可关闭)。全屏流程图仍以 `fixed` 遮罩覆盖视口,独立于上述布局。
```
┌──────────────────────────────────────────────────────────────────┐
│ 顶栏:标题 · SixStepProgressHorizontal · 输出格式 · 聊天记录 · 流程图 │
├───┬────────────────────────────────────────────────────┬─────────┤
│会话│ SixStepConversation(多轮结构化阶段) │ 流程图 │
│列表│ + ChatInputComposer │ 沙箱 │
│轨 │ │(可关闭) │
└───┴────────────────────────────────────────────────────┴─────────┘
```
---
## 二、目录结构与各文件职责
```
src/components/angentchat/
│
├── types.ts # 数据模型类型(含 phase / intent / rawSteps 等)
├── mockData.ts # 本地模拟流式控制器(与 api 侧事件对齐,便于离线演示)
├── api.ts # runIntentOrchestrateStream、runSixStepDeduction、runExpertSearch/Create
├── flowLayout.ts # SVG 流程图布局
├── styles.css # 动画与工具类
│
├── SixStepChatPage.tsx # 推演主页面(状态中枢)
├── SixStepProgressHorizontal.tsx# 顶栏横向主/子步骤进度(主页面使用)
├── SixStepProgress.tsx # 纵向进度面板(组件保留;布局以横向为准)
├── SixStepChatHistory.tsx # 会话列表轨(展开/收起)
├── SixStepConversation.tsx # 对话区(阶段卡片 + 专家匹配)
├── SixStepCards.tsx # 阶段卡片原子组件
├── ExpertMatchCards.tsx # 专家匹配卡片
├── McpSelectModal.tsx # MCP 多选确认弹窗
├── StreamErrorNotice.tsx # 助手消息底部:汇总 SSE error_log / 网络类错误的可折叠提示条
├── SixStepFlowSandbox.tsx # 右侧流程图沙箱
├── SixStepFlowFullscreen.tsx # 全屏流程图
├── AgentCardList.tsx # 路由 sixstep-agents:MCP 卡片列表
├── AgentEditPage.tsx # 路由 sixstep-agent-edit:单 MCP 调试编辑
├── AgentChatMessageList.tsx # 智能体对话消息列表(编辑页等)
├── INTENT_INTEGRATION.md # 意图流与前后端字段说明(对接备忘)
└── doc.md # 本文档
```
---
## 三、数据模型(`types.ts`)
所有类型均从此文件导出,是各组件之间传递数据的唯一契约。
### 3.1 基础枚举
| 类型 | 取值 | 说明 |
|------|------|------|
| `MainStepKey` | `'info' \| 'sixstep' \| 'final'` | 三个主步骤的键名 |
| `SubStepKey` | `'purpose' \| 'actor' \| 'behavior' \| 'narrative'` | 6步法的四个子阶段键名 |
| `StepStatus` | `'pending' \| 'running' \| 'done'` | 步骤状态,驱动进度面板和动画 |
| `SixStepPhase` | `'idle'` \| `'intent-detecting'` \| `'expert-match'` \| `'expert-confirm'` \| `'generate'` \| `'paused'` \| `'completed'` \| `'clarifying'` \| `'follow-up'` | 助手消息所处交互阶段(与编排流、停止、追问等配合) |
| `SelectableMcpKey` | `'purpose' \| 'actor' \| 'behavior_factor' \| 'narrative'` | MCP 弹窗可选子链,会随请求传给后端 `selected_mcps` |
`BackendStepResult`:与后端 `step_end` 原始结构对齐,用于 **`continue_analysis`** 时把 `last_steps` 回传编排接口;`SixStepMessage.rawSteps` 在流式过程中累积,`resumedFrom` 对应 **`resume`** 事件中的继续起点。
### 3.2 卡片数据结构
每类卡片都有独立接口,并通过外键(`purposeId`、`actorId`、`driverId`)建立父子关联,构成树形层级关系。
```
PurposeCard (目的)
└─ ActorCard.purposeId → PurposeCard.id
└─ BehaviorDriverCard.actorId → ActorCard.id
└─ NarrativeCard.driverId → BehaviorDriverCard.id
```
| 接口 | 字段 | 说明 |
|------|------|------|
| `PurposeCard` | `id, index, title, reason` | 目的卡片,包含标题和推演理由 |
| `ActorCard` | `id, index, purposeId, title, content` | 行为体卡片,`purposeId` 关联所属目的 |
| `BehaviorDriverCard` | `id, index, actorId, expectedBehavior, driver` | 预期行为 + 驱动因素二合一卡片,`actorId` 关联所属行为体 |
| `NarrativeCard` | `id, index, driverId, title, reason` | 叙事卡片,`driverId` 关联所属驱动因素(`BehaviorDriverCard.id`) |
### 3.3 阶段数据结构
每个子阶段有独立的 `*StageData` 接口:
```typescript
interface PurposeStageData {
thinking: string; // 模型思考过程文本(流式填充)
cards: PurposeCard[]; // 已生成的卡片列表(逐张追加)
complete: boolean; // 该阶段是否完全结束
}
// ActorStageData / BehaviorStageData / NarrativeStageData 结构类似
```
### 3.4 消息总线:`SixStepMessage`
这是整个页面最核心的数据结构,一条助手消息包含所有推演状态:
```typescript
interface SixStepMessage {
id: string;
role: 'user' | 'assistant';
content?: string;
timestamp: Date;
outputFormat?: 'structured' | 'markdown';
markdownContent?: string;
stages?: SixStepStages;
finalAnswer?: string;
mainStepStatus?: Record<MainStepKey, StepStatus>;
subStepStatus?: Record<SubStepKey, StepStatus>;
currentSubStep?: SubStepKey;
currentMainStep?: MainStepKey;
phase?: SixStepPhase;
expertMatch?: ExpertMatchData;
selectedExpert?: ExpertCard;
selectedMcps?: SelectableMcpKey[];
intent?: { name: string; confidence?: number; reason?: string };
clarifyQuestion?: string;
rawSteps?: BackendStepResult[];
resumedFrom?: string;
effectiveUserInput?: string;
errors?: StreamErrorItem[];
}
```
**`errors` / `StreamErrorItem`**:`runIntentOrchestrateStream` 收到 SSE **`error_log`** 时追加;致命失败仍会走 **`error`** 事件并映射为同类结构。`SixStepConversation` 中 **`StreamErrorNotice`** 读取 `message.errors` 展示摘要(可折叠列表)。
**关键设计**:`mainStepStatus` / `subStepStatus` 驱动顶栏 `SixStepProgressHorizontal`;`stages` 驱动中间对话渲染;`currentSubStep` 驱动流程图沙箱蚂蚁线。`phase` / `intent` / `rawSteps` 与编排流、暂停继续、追问分支一致。所有信息集中在一个对象内,组件通过 props 按需取用。
---
## 四、模拟数据生成器(`mockData.ts`)
> **线上主路径**:`SixStepChatPage` 使用 **`runIntentOrchestrateStream`** 消费真实 `/api/orchestrate/stream`。`mockData.ts` 中的 **`runSixStepDeduction`** 仍保留,用于本地演示或与 `api.ts` 中纯编排解析逻辑对齐;新功能(意图、专家 SSE、`resume` 等)以 `runIntentOrchestrateStream` 为准。
### 4.1 种子数据
文件顶部定义了四组静态种子数组:
| 变量 | 长度 | 用途 |
|------|------|------|
| `PURPOSE_SEED` | 3 | 目的卡片内容模板 |
| `ACTOR_SEED` | 7 | 行为体卡片内容模板 |
| `BEHAVIOR_SEED` | 8 | 预期行为与驱动因素卡片内容模板 |
| `NARRATIVE_SEED` | 8 | 叙事卡片内容模板 |
使用时循环取模(`(counter - 1) % SEED.length`),确保不越界。
### 4.2 卡片生成规则
| 阶段 | 数量规则 |
|------|---------|
| 目的 | 固定 3 张(取自 `PURPOSE_SEED` 全量) |
| 行为体 | 每个目的对应的数量:第 0 个目的 2 张,其余 1 张,共 4 张 |
| 预期行为/驱动 | 每个行为体:偶数索引 2 张,奇数索引 1 张,共 6 张 |
| 叙事 | 每个预期行为:偶数索引 2 张,奇数索引 1 张,共 9 张 |
最终流程图节点总数:`1(智能体)+ 3 + 4 + 6×2(预期行为+驱动因素)+ 9 = 29 节点`。
### 4.3 流式控制器:`runSixStepDeduction`
```typescript
export function runSixStepDeduction(
message: SixStepMessage, // 直接在此对象上原地修改字段
options: {
onUpdate: (msg: SixStepMessage) => void; // 每次有新内容时回调
onComplete?: () => void; // 全部完成时回调
}
): SixStepMockController
```
返回的 `SixStepMockController` 包含:
- `message`: 正在被修改的消息对象引用
- `stop()`: 立即取消所有待执行的 `setTimeout`,中止推演
- `promise`: 推演完成后 resolve 的 Promise(stop 后不再 resolve)
**内部时序**(所有延迟均可通过顶部常量调整):
```
常量 默认值 说明
TYPE_INTERVAL 28ms 每次打字输出 4 个字符后的等待间隔
STAGE_GAP 380ms 阶段切换之间的等待时长
```
执行顺序(伪代码):
```
setMain('info', 'running') → emit
wait(800ms) ← 模拟信息准备耗时
setMain('info', 'done')
setMain('sixstep', 'running') → emit
wait(STAGE_GAP)
setSub('purpose', 'running') → emit
typeInto(PURPOSE_THINKING) ← 逐字打出思考过程
append cards 逐张(wait 280ms/张)
setSub('purpose', 'done') → emit
wait(STAGE_GAP)
setSub('actor', 'running') → emit
typeInto(ACTOR_THINKING)
append cards 逐张(wait 220ms/张)
setSub('actor', 'done') → emit
wait(STAGE_GAP)
setSub('behavior', 'running') → emit
typeInto(BEHAVIOR_THINKING)
append cards 逐张(wait 200ms/张)
setSub('behavior', 'done') → emit
wait(STAGE_GAP)
setSub('narrative', 'running') → emit
typeInto(NARRATIVE_THINKING)
append cards 逐张(wait 200ms/张)
setSub('narrative', 'done')
setMain('sixstep', 'done')
setMain('final', 'running') → emit
wait(STAGE_GAP)
typeInto(FINAL_ANSWER) ← 逐字打出最终结论
setMain('final', 'done') → emit
onComplete()
```
**`emit` 机制**:每次调用 `onUpdate({ ...message, stages: { ...message.stages } })`,将消息对象浅拷贝后传给父组件,触发 React re-render。
---
## 五、流程图布局工具(`flowLayout.ts`)
此文件被沙箱和全屏组件共同引用,避免重复计算。
### 5.1 核心函数:`buildLayout`
```typescript
function buildLayout(
purposes: PurposeCard[],
actors: ActorCard[],
behaviors: BehaviorDriverCard[],
narratives: NarrativeCard[],
options?: BuildLayoutOptions // 可覆盖节点尺寸和间距,以及根节点文案
): FlowLayout
```
**`BuildLayoutOptions` 扩展**:除 `nodeWidth`、`nodeHeight`、`levelGap`、`nodeGap`、`paddingX`、`paddingY` 外,可选 **`rootTitle`**、**`rootDetail`**。用于将层级 0 的 `agent-root` 从默认「智能体 / 协同推演…」改为 **当前已选专家**(`SixStepFlowSandbox` / `SixStepFlowFullscreen` 在存在 `message.selectedExpert` 时传入专家姓名与简介摘要)。
返回 `FlowLayout`:
```typescript
interface FlowLayout {
nodes: LayoutNode[]; // 已计算好坐标的节点列表
edges: LayoutEdge[]; // 已确定连接关系和颜色对的边列表
width: number; // 整体画布宽度(用于 SVG viewBox)
height: number; // 整体画布高度
}
```
**布局算法**:改良的"叶节点优先后序遍历":
1. 从 agent 根节点开始,递归 DFS 处理子树
2. 叶节点(无子节点的节点)按从左到右顺序分配 x 坐标,每次递增 `nodeWidth + nodeGap`
3. 非叶节点的 x = (最左子节点 x + 最右子节点 x) / 2,确保父节点居中于子节点上方
4. 所有节点的 y = `paddingY + level × (nodeHeight + levelGap)`,level 即树的深度
节点层级(`level`)对应关系:
| level | variant | 说明 |
|-------|---------|------|
| 0 | `agent` | 智能体根节点 |
| 1 | `purpose` | 目的 |
| 2 | `actor` | 行为体 |
| 3 | `behavior` | 预期行为 |
| 4 | `driver` | 驱动因素 |
| 5 | `narrative` | 叙事 |
注意:`BehaviorDriverCard` 一张卡片会生成两个节点(`behavior` + `driver`),id 分别为 `${card.id}-behavior` 和 `${card.id}-driver`。
### 5.2 共享常量
| 常量 | 说明 |
|------|------|
| `EDGE_COLOR` | 按边类型分别定义深色/浅色主题颜色(5 组) |
| `EDGE_LABEL` | 每种边类型的中文图例说明 |
| `CURRENT_PAIR_BY_STAGE` | `SubStepKey → LayoutEdge['pair']` 映射,决定哪组边在哪个阶段展示蚂蚁线 |
| `STATUS_TITLE` | `SubStepKey → string` 映射,沙箱/全屏顶部标题文字 |
| `VARIANT_ABBREV` | 节点类型 → 简化缩写(沙箱专用,如 `purpose → 'md'`) |
| `VARIANT_LABEL` | 节点类型 → 完整中文标签 |
### 5.3 辅助函数:`buildEdgePath`
生成 SVG 三次贝塞尔曲线路径字符串:
```
M x1 y1 C x1 my, x2 my, x2 y2
```
控制点均取中间 y 坐标(`(y1+y2)/2`),形成平滑 S 型连线,不与节点重叠。
---
## 六、CSS 动画(`styles.css`)
| 类名 | 动画 | 用途 |
|------|------|------|
| `.sixstep-ant-line` | `stroke-dasharray: 6 6` + `dashoffset` 循环 | SVG 边上的蚂蚁线流动效果(当阶段正在生成时) |
| `.sixstep-pulse` | box-shadow 呼吸光晕 | 进度面板当前步骤图标、沙箱标题图标 |
| `.sixstep-shimmer-text` / `...-light` | 渐变横扫文字 | 进度面板"生成中"文字、沙箱标题生成中状态 |
| `.sixstep-spin-soft` | 360° 匀速旋转 | loading 图标 |
| `.sixstep-fade-up` | 从下方淡入 | 子阶段卡片逐步出现 |
| `.sixstep-node-pop` | scale 弹出 + 高光 | 流程图新增节点的弹出动画(持续 600ms) |
| `.sixstep-ring-burst` | SVG circle `r` 从 0 扩散到 60,同时 opacity→0 | 节点出现时的扩散光环效果(持续 700ms) |
| `.sixstep-canvas-grab` / `.sixstep-canvas-grabbing` | `cursor: grab/grabbing` | 流程图画布拖拽手型 |
| `.sixstep-toolbar-btn` | 基础尺寸 + hover 过渡 | 工具栏图标按钮 |
| `.sixstep-minimap-rect` | 静态样式 | 缩略图视口矩形指示框 |
| `.sixstep-resize-handle` | 竖线拖拽手柄 | 沙箱左边缘的宽度调节拖拽条 |
---
## 七、主页面(`SixStepChatPage.tsx`)
### 7.1 职责
主页面是整个功能的**状态中枢**,负责:
- 多会话列表与 **`/api/conversations`** 持久化(`filter_key` / `filter_value` 标识 6bf 页)。
- 创建并管理 **`runIntentOrchestrateStream`**(编排 + 意图)与 **`runExpertSearch` / `runExpertCreate`**(仍可用于「换一批专家」「创建专家」等直连 invoke)的控制器生命周期。
- 将状态与专家相关回调分发给 `SixStepConversation`、`McpSelectModal`、`SixStepFlowSandbox` 等。
- 管理沙箱宽度拖拽。
### 7.2 状态一览(节选)
| 状态 | 类型 | 说明 |
|------|------|------|
| `conversations` / `activeConvId` | 多会话 | 当前选中会话及其 `messages` |
| `input` | `string` | 输入框文本 |
| `isGenerating` | `boolean` | 是否正在请求流式接口 |
| `outputFormat` | `'structured' \| 'markdown'` | 传给后端的 `output_format` |
| `sandboxOpen` / `sandboxWidth` / `fullscreenOpen` | UI | 流程图沙箱与全屏 |
| `historyOpen` / `loading` | UI / 加载 | 聊天记录侧栏与首屏拉取 |
| `activeAssistantMsgId` | `string \| null` | 当前高亮/流式更新的助手消息 |
| `mcpModal` | `{ open, messageId, expert }` | MCP 多选弹窗状态 |
### 7.3 Ref 一览
| Ref | 类型 | 用途 |
|-----|------|------|
| `controllerRef` | `SixStepMockController \| null` | `runIntentOrchestrateStream` 的 `stop()` |
| `expertControllerRef` | `ExpertRunController \| null` | 专家搜索/创建流的 `stop()` |
| `conversationsRef` | 会话数组 | 持久化前读取最新消息 |
| `containerRef` / `resizeRef` | DOM / 拖拽 | 沙箱宽度拖拽 |
### 7.4 核心逻辑
**`handleSend`**
1. 追加用户消息;创建初始助手消息(`buildInitialAssistantMessage`,`phase` 随流程推进)。
2. 调用 **`runIntentOrchestrateStream`**,请求体为 **`IntentOrchestrateRequest`**:`user_input`、`output_format`、`intent_mode`(默认 `auto`)、`current_phase`(含上一条助手为 **`paused`** 时传 `paused`)、`selected_expert`、`selected_mcps`、`expert_candidates`、**`last_steps`(来自上一条助手 `rawSteps`)**、`conversation_history` 等。
- 暂停继续时会继承上一条助手的 `rawSteps` 与 `selectedMcps`(`pausedSelectedMcps`),并显式回传 `selected_mcps` / `last_steps`,避免恢复时丢失步骤选择与历史上下文。
3. `onUpdate` 内更新当前会话中对应 `id` 的助手消息;结束后 `persistConversation`。
**专家与 MCP**
- 用户确认专家后 **`handleMcpModalConfirm`**:写入 `selectedExpert` / `selectedMcps`,并可用 **`intent_mode: 'force_orchestrate'`** 立即启动分析(无需用户再次输入)。
- **`handleRefetchExpert`**:可用 `force_find_expert` 并排除已有候选。
**`handleStop`**
- `AbortController` 中止流;将未完成步骤从 `running` 收回;助手 **`phase` 置为 `paused`**,便于下次发送走 **`continue_analysis`** / `rerun_from_step` / `revise_input_continue` / `change_steps_continue` / `edit_stage_continue`。
**流内关键事件补充**
- `resume`:恢复继续分析时,会先把已有 `stages` 中的 `purpose/actor/behavior` 卡片回填到转换缓存,确保后续 `step_end` 的父子映射准确。
- `rerun`:前端会按 `affected_steps` 清空受影响阶段(`stages`、`subStepStatus`、`rawSteps` 与局部卡片缓存),再接收重跑后的 `step_*`。
- `change_steps`:按 `removed_steps` 清理被移除阶段与 `rawSteps`,保留未移除阶段并继续执行 `steps_to_run`。
- `edit_apply`:先更新被编辑卡片内容,再按 `affected_steps` 清理后续阶段并触发重跑。
- `intent`:包含 `slots`(如 `step`、`new_input`),用于更细粒度前端状态更新。
**沙箱宽度拖拽**:逻辑同前(`mousemove` / `mouseup`,保证对话区最小宽度)。
**活跃助手消息派生**:
```typescript
const activeAssistantId = /* activeAssistantMsgId 或最后一条 assistant */;
const activeAssistant = messages.find((m) => m.id === activeAssistantId);
const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
```
`liveMainStatus` / `liveSubStatus` 传给 **`SixStepProgressHorizontal`**;`activeAssistant` 传给沙箱与全屏。
### 7.5 Props 传递图
```
SixStepChatPage
│
├─► SixStepChatHistory # 会话列表(与顶栏「聊天记录」联动)
├─► SixStepProgressHorizontal # mainStatus / subStatus / isDark
├─► SixStepConversation
│ messages, isGenerating, activeMessageId,
│ expertHandlers(选专家 / 换一批 / 创建 / 确认)
├─► ChatInputComposer # onSend → handleSend, onStop → handleStop
├─► SixStepFlowSandbox # message={activeAssistant}
├─► SixStepFlowFullscreen
└─► McpSelectModal # 专家确认后选 MCP
```
---
## 八、左侧进度面板(`SixStepProgress.tsx`)
> **主页面顶栏**使用的是 **`SixStepProgressHorizontal.tsx`**(横向主/子步骤);本节描述纵向 `SixStepProgress` 的 Props 与交互,组件仍保留供其他布局复用。
### 8.1 Props
| Prop | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mainStatus` | `Record<MainStepKey, StepStatus>` | ✓ | 三主步骤状态 |
| `subStatus` | `Record<SubStepKey, StepStatus>` | ✓ | 四子阶段状态 |
| `collapsed` | `boolean` | 可选 | 是否折叠,默认 `false` |
| `onToggleCollapse` | `() => void` | 可选 | 展开/收起回调 |
### 8.2 展开视图(`collapsed = false`)
从上到下展示三个主步骤卡片,每个卡片包含:
- 彩色图标(每个步骤有独立配色,running 时呼吸脉冲)
- 标题 + 进度徽标(`已完成 / 生成中 / 待开始`)
- 副文本描述(running 时展示 shimmer 动画)
- 步骤之间用竖线连接(完成后变绿色)
当 `sixstep` 阶段进入 running 或 done,在其卡片内部展开四个子阶段列表(`SUB_STEPS`),每个子阶段包含:
- 彩色图标(与流程图对应色)
- 标题 + 状态图标
- 时间线左侧的节点圆点 + 连接竖线
右上角收起按钮(`ChevronLeft`)触发 `onToggleCollapse`。
### 8.3 折叠视图(`collapsed = true`)
渲染轻量的 `CollapsedView` 子组件:
- 宽度由父容器通过 `w-14` 控制(56px),有 `transition-all duration-300` 过渡动画
- 顶部 `ChevronRight` 按钮用于展开
- 竖向排列三个主步骤彩色图标 + 状态圆点
- 若 `sixstep` 已解锁,在其下方额外展示四个小尺寸子阶段图标(`w-7 h-7`)
- 所有元素均有 `title` 属性,鼠标悬停显示步骤名称
### 8.4 配色方案
步骤颜色定义在 `MAIN_STEPS` 和 `SUB_STEPS` 数组的 `color` 字段中:
| 步骤 | 深色 | 浅色 |
|------|------|------|
| 信息准备 | sky-300 / sky-500 | sky-600 / sky-50 |
| 6步法推演 | violet-300 / violet-500 | violet-600 / violet-50 |
| 最终结果 | emerald-300 / emerald-500 | emerald-600 / emerald-50 |
| 目的 | violet-300 | orange |
| 行为体 | cyan-300 | red |
| 预期行为/驱动 | emerald-300 | emerald |
| 叙事 | amber-300 | amber |
---
## 九、对话列表(`SixStepConversation.tsx`)
### 9.1 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `messages` | `SixStepMessage[]` | 全部消息 |
| `isGenerating` | `boolean` | 是否正在生成 |
| `activeMessageId` | `string \| null` | 当前流式聚焦的助手消息 id |
| `expertHandlers` | 可选对象 | `onSelectExpert` / `onRefetchExpert` / `onCreateExpert` / `onConfirmExpert` |
### 9.2 消息渲染策略
每条消息根据 `role` 字段分叉:
- `role === 'user'` → 渲染 `UserMessage`:右对齐气泡 + 用户头像
- `role === 'assistant'` → 渲染 `AssistantMessage`:左对齐 + 机器人头像(生成中时呼吸脉冲 + 星光图标)
消息列表容器 `ref` 监听内容变化,每次 `messages` 或 `isGenerating` 变化后自动滚动到底部。
### 9.3 助手消息 `AssistantMessage`
内部读取 `message.stages` 和 `message.mainStepStatus`、`message.subStepStatus`,根据是否存在各阶段数据来条件渲染对应的阶段块。底部(及部分分支末尾)挂载 **`StreamErrorNotice`**,展示 **`message.errors`**(来自 SSE `error_log` 等)。
助手消息结构示意:
```
AssistantMessage
│
├─ 信息准备中(仅当 info=running 且无任何 stage)→ shimmer 提示
│
├─ PurposeStage (当 stages.purpose 存在时)
│ ├─ InlineCollapsible (以 StageHeader 为标题)
│ │ ├─ ThinkingBlock (思考过程)
│ │ └─ InlineCollapsible (目的卡片列表)
│ │ └─ PurposeCardItem × N
│
├─ ActorStage (当 stages.actor 存在时)
│ ├─ InlineCollapsible
│ │ ├─ ThinkingBlock
│ │ └─ InlineCollapsible (行为体卡片列表)
│ │ └─ 按 purpose 分组
│ │ ├─ ParentGroupHeader (所属目的)
│ │ └─ ActorCardItem × N
│
├─ BehaviorStage (当 stages.behavior 存在时)
│ ├─ InlineCollapsible
│ │ ├─ ThinkingBlock
│ │ └─ InlineCollapsible (预期行为/驱动因素卡片列表)
│ │ └─ 按 actor 分组
│ │ ├─ ParentGroupHeader (所属行为体)
│ │ └─ BehaviorCardItem × N(2列网格)
│
├─ NarrativeStage (当 stages.narrative 存在时)
│ ├─ InlineCollapsible
│ │ ├─ ThinkingBlock
│ │ └─ InlineCollapsible (叙事卡片列表)
│ │ └─ 按 driver 分组
│ │ ├─ ParentGroupHeader (所属驱动因素)
│ │ └─ NarrativeCardItem × N
│
└─ FinalAnswer (当 finalAnswer 存在或 final=running 时)
│
└─ StreamErrorNotice (当 message.errors 非空)
```
**分组计算**:`ActorStage`、`BehaviorStage`、`NarrativeStage` 都在内部用 `useMemo` 计算分组,将卡片按父 ID 聚合到 Map,再按父实体列表顺序输出。
---
## 十、卡片原子组件(`SixStepCards.tsx`)
此文件作为"组件库",为对话列表提供所有可复用的卡片和容器组件。
### 10.1 `InlineCollapsible`
通用折叠面板,思考过程区块、卡片列表区块均复用此组件。
| Prop | 说明 |
|------|------|
| `title` | 标题区 ReactNode |
| `children` | 折叠内容 |
| `defaultOpen` | 默认展开状态(默认 `true`) |
| `variant` | `'default'`(标准边框背景)或 `'subtle'`(更淡)|
展开/收起使用 `grid-template-rows` 动画(`0fr → 1fr`),配合 `opacity` 过渡,无需 JS 计算高度。
### 10.2 各卡片组件
| 组件 | 卡片类型 | 布局特点 |
|------|---------|---------|
| `PurposeCardItem` | 目的 | 紫/橙渐变背景,索引徽标 + Target 图标 + 标题 + 理由 |
| `ActorCardItem` | 行为体 | 青/红渐变背景,UserCheck 图标 + 标题 + 内容 |
| `BehaviorCardItem` | 预期行为/驱动因素 | 翠绿渐变背景,上下两部分:TrendingUp 图标+预期行为 / Zap 图标+驱动因素 |
| `NarrativeCardItem` | 叙事 | 琥珀渐变背景,ScrollText 图标 + 标题 + 理由 |
所有卡片均有 `.sixstep-fade-up` 进入动画,`hover` 时边框加深。
### 10.3 `ParentGroupHeader`
显示某类卡片所属父实体名称的标签行,用于行为体、预期行为/驱动、叙事列表的分组标题。
| Prop | 说明 |
|------|------|
| `label` | 关系说明(如"所属目的") |
| `parentTitle` | 父实体名称 |
| `count` | 该组卡片数量 |
| `variant` | `'actor' \| 'behavior' \| 'narrative'`,决定图标和配色 |
### 10.4 `ThinkingBlock`
展示思考过程文本的区块,`loading` 为 `true` 时标题行展示 "生成中" shimmer 文字。
### 10.5 `StageHeader`
折叠面板标题,包含阶段图标、阶段标题、卡片数量徽标、状态徽标(已完成/生成中)。`loading = true` 时图标呼吸脉冲,副文本展示 shimmer 动画。
---
## 十一、流程图沙箱(`SixStepFlowSandbox.tsx`)
### 11.1 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `message` | `SixStepMessage \| undefined` | 活跃助手消息,用于提取各阶段卡片数据 |
| `width` | `number` | 当前沙箱宽度(px),由主页面拖拽控制 |
| `onResizeStart` | `(e: React.MouseEvent) => void` | 拖拽手柄 mousedown 回调 |
| `onClose` | `() => void` | 关闭沙箱回调 |
| `onOpenFullscreen` | `() => void` | 打开全屏回调 |
### 11.2 布局参数(`COMPACT_OPTIONS`)
沙箱使用缩小版布局,节点小于全屏版:
```typescript
const COMPACT_OPTIONS = {
nodeWidth: 120, nodeHeight: 60,
levelGap: 80, nodeGap: 24,
paddingX: 32, paddingY: 28,
};
```
### 11.3 简化节点(`CompactNode`)
每个节点只显示**彩色图标 + 阶段缩写**(`VARIANT_ABBREV`):
| 层级 | 缩写 |
|------|------|
| 智能体(或根标题改为已选专家名时仍为 variant `agent`) | AI |
| 目的 | md |
| 行为体 | xwt |
| 预期行为 | yqxw |
| 驱动因素 | qdys |
| 叙事 | xs |
底部固定图例说明对应关系。
### 11.4 新增节点动画
通过 `seenIdsRef`(`Set<string>`)记录已渲染过的节点 ID。每次 `layout.nodes` 变化时,找出新增的 ID,将其加入 `appearingIds` 状态,700ms 后自动移除。
`CompactNode` 接收 `appearing` prop,为 `true` 时:
1. 为 `<g>` 添加 `.sixstep-node-pop` CSS 类(scale 弹出)
2. 在节点中心绘制一个 SVG `<circle>` 并添加 `.sixstep-ring-burst` CSS 类(扩散光环)
### 11.5 交互能力
**平移(Pan)**:
- `mousedown` 记录起始坐标和初始 `tx/ty` 到 `panStart.current`
- 通过 `useEffect` 监听 `isPanning` 状态,动态注册/注销 `window` 的 `mousemove` 和 `mouseup`
- `mousemove` 计算偏移量 `(e.clientX - startX, e.clientY - startY)` 叠加到初始平移值
**缩放(Zoom)**:
- 通过 `useEffect` 向 `containerRef` 注册原生 `wheel` 事件(`{ passive: false }`),确保 `e.preventDefault()` 生效,不触发浏览器页面缩放
- 以鼠标位置为缩放中心:`nextTx = mx - (mx - prev.tx) * (nextScale / prev.scale)`
- 缩放范围:0.3x ~ 2.5x
**工具栏**:
| 按钮 | 功能 |
|------|------|
| `+` | 按 1.2 倍放大 |
| `-` | 按 1.2 倍缩小 |
| ↺ | 恢复 1:1(translate 0,0 scale 1) |
| ⊕ | 定位画布中央(保持当前缩放比,调整平移使图形居中) |
| ⤢ | 触发 `onOpenFullscreen` |
首次有内容时自动执行 `fitToView`(等比缩放 + 居中),由 `fitAppliedRef` 控制只执行一次。
### 11.6 连线说明
SVG 中的 `<defs>` 为每种边类型各定义一个箭头 marker(id 加 `sixstep-sandbox-` 前缀,与全屏版 marker 不冲突)。当 `activePair === edge.pair` 时,该边添加 `.sixstep-ant-line` CSS 类展示蚂蚁线动画。
### 11.7 调整宽度
左侧有一个 `position: absolute` 的 `.sixstep-resize-handle` 竖线拖拽条,`mousedown` 触发 `onResizeStart`(由主页面处理具体逻辑)。
---
## 十二、全屏流程图视图(`SixStepFlowFullscreen.tsx`)
### 12.1 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `message` | `SixStepMessage \| undefined` | 与沙箱相同的活跃助手消息 |
| `onClose` | `() => void` | 关闭全屏回调 |
### 12.2 与沙箱的区别
| 特性 | 沙箱 | 全屏 |
|------|------|------|
| 布局参数 | 紧凑(节点 120×60) | 标准(节点 200×84) |
| 节点内容 | 缩写 + 图标 | 完整标题 + 详情 + 图标 |
| 节点出现动画 | 有(pop + ring-burst) | 无 |
| 子树收起/展开 | 无 | 有(点击**有子节点**的父节点;右下角「-」收起 / 「+」展开;画布左下角有操作提示) |
| 缩略图 | 无 | 有(右下角;仅绘制当前可见节点) |
| 下载 | 无 | 有(导出 SVG) |
| Esc 关闭 | 无 | 有 |
| 层级 | flex 子元素 | `fixed inset-0 z-50`(全视口遮罩) |
| 缩放范围 | 0.3x ~ 2.5x | 0.2x ~ 3x |
### 12.3 详细节点(`DetailNode`)
使用 `foreignObject` 嵌入 HTML,展示:
- 彩色图标包裹 + 大写类型标签(`VARIANT_LABEL`)
- 节点标题(`line-clamp-1`)
- 详情内容(`line-clamp-2`)
深色/浅色主题的渐变背景、图标颜色均通过 `VARIANT_TOKEN` 查表获取。
**收起子树**:当 `flowLayout` 中节点带 **`children`** 时,卡片右上角显示 **`+` / `-`**,点击节点(或该角标)切换 **`collapsedNodeIds`**:自该节点向下的后代节点与相关边不再纳入 **`targetVisibleNodeSet`**,并以约 **240ms** 的透明度过渡(进入/离开集合合并渲染,避免闪烁)。重新生成布局时会剔除已不存在的节点 id,避免残留收起状态。根节点若被选为专家展示,仍可按树结构收起其下子链。
### 12.4 缩略图(`Minimap`)
固定 220×150px,渲染在右下角。
内部逻辑:
1. 计算整体布局与 minimap 尺寸的缩放比 `ratio = Math.min((W-10)/layoutW, (H-10)/layoutH)`
2. 按 `ratio` 缩小绘制每个节点矩形(使用 `MINIMAP_VARIANT_FILL` 颜色,无文字)
3. 根据当前 `transform`(`tx, ty, scale`)和视口尺寸,计算可见区域在世界坐标的矩形,再乘 `ratio` 映射到 minimap 坐标系,绘制蓝色描边矩形(`.sixstep-minimap-rect`)
4. 点击 minimap:将点击的 minimap 坐标逆映射到世界坐标,调用 `onJump` 将视口移动到以该点为中心
### 12.5 SVG 下载
点击下载按钮时:
1. 克隆 `svgRef.current`(当前全屏 SVG 元素)
2. 设置 `width/height` 为实际布局尺寸,移除 transform
3. 用 `XMLSerializer` 序列化为字符串
4. 创建 `Blob` 和临时 `<a>` 标签,触发下载
5. 文件名格式:`6步法推演-YYYY-MM-DD-HH-mm-ss.svg`
> ⚠️ 注意:由于节点内容在 `foreignObject` 中使用 HTML + CSS 类名,下载的 SVG 打开时若外部查看器不支持 CSS,样式会丢失。可在未来改为纯 SVG 文本节点渲染以提升兼容性。
---
## 十三、整体数据流图
```
用户输入(回车)
│
▼
SixStepChatPage.handleSend()
│
├─► 当前会话追加 UserMessage + 初始 AssistantMessage
│
└─► runIntentOrchestrateStream(assistant, { onUpdate, onComplete }, IntentOrchestrateRequest)
│
│ readSSE('/api/orchestrate/stream', body)
│
│ intent / expert_* / clarify / resume / rerun / change_steps / edit_apply
│ start / step_* / flow / done / delta(追问或 markdown)
│ 每次 onUpdate → 更新会话中该 assistant 的 SixStepMessage
│
▼ React re-render
│
├─► SixStepProgressHorizontal(liveMainStatus / liveSubStatus)
├─► SixStepConversation(messages、expertHandlers、intent/clarify 等)
├─► SixStepFlowSandbox / Fullscreen(stages → buildLayout)
└─► persistConversation(SQLite)
```
---
## 十四、路由注册
**`src/App.tsx`**:
```tsx
// 路由定义
<Route path="sixstep-chat" element={<SixStepChatPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
// 导航路径映射(handleNavigate 函数)
else if ([..., 'sixstep-chat'].includes(page)) {
path = `/qa-center/${page}`;
}
```
**可观测性页面**(调用耗时 / 错误日志筛选):全站路由 **`/observability`**,侧栏顶级「可观测性」,对接后端 **`GET /api/observability/calls`**、**`/errors`**、**`/options`** 等(详见 `backend/doc.md`)。与 6bf 页面独立,不在 `/qa-center` 前缀下。
**`src/components/Sidebar.tsx`**:
```tsx
// 菜单配置(问答中心 children)
{ id: 'sixstep-chat', label: '6bf', icon: Workflow }
// qaPages 集合(控制问答中心父菜单高亮)
const qaPages = new Set([..., 'sixstep-chat']);
```
---
## 十五、流式错误与 `error_log`(`api.ts`)
编排 SSE 除 **`error`** 外,可能下发 **`error_log`**(步骤失败摘要、skill 降级等)。`runIntentOrchestrateStream` 将其归一为 **`StreamErrorItem`** 并 **`push` 到 `message.errors`**;网络异常也会写入类似结构。后端在可执行 **skill 失败已跳过**时,**`message` 文案**可能附带 **异常原因前若干字符**(与 `backend/app/mcp/base.py` 截断策略一致),便于在气泡内区分超时与其它错误。
**`StreamErrorNotice`**:仅在对应助手气泡内展示,支持折叠展开、错误/警告计数摘要;不参与 `showAnalysisContent` 隐藏逻辑(专家阶段亦可显示)。
---
## 十六、扩展指南
### 接入编排与意图流
1. 主入口为 **`runIntentOrchestrateStream`**(`api.ts`),与后端 **`/api/orchestrate/stream`** 事件一一对应;扩展新意图时通常同时改 **`backend/app/intent/detector.py`** 与 **`runIntentOrchestrateStream`** 的分支解析。
- 当前除 `continue_analysis` 外,还支持 `rerun_from_step`、`revise_input_continue`、`change_steps_continue`、`edit_stage_continue` 分支。
2. 保持 **`onUpdate(SixStepMessage)`** 契约:`SixStepChatPage` 只需合并助手消息字段。
3. 纯编排、无意图分支的调试可继续用 **`runSixStepDeduction`**(同文件内,仅 `start` / `step_*` / `flow` / `done`)。
详见 **`INTENT_INTEGRATION.md`** 与仓库 **`docs/xvqiu.md`**。
### 某子步(如行为体、叙事)体感很慢时
前端 **`step_end`** 事件里会带上该步的 **`duration_ms`**(整步总耗时),便于在 UI 或调试时对比各步。
更细拆分(skill 耗时 vs 拼 prompt 扩展 vs LLM)在**后端**完成:每次 `POST /api/agents/{key}/invoke` 及编排内对 `MCPAgent.invoke` 的调用,服务端会打 **`mcp_timing`** / **`mcp_skill_timing`** 日志(见 **`backend/doc.md`**「MCP 调用接口」与 **`backend/DEVELOPMENT.md`** §5.2)。排查时在运行 `python -m app.main` 的终端查看即可;无需改前端请求体。
### 替换流程图引擎(如 G6 / React Flow)
仅需替换 `SixStepFlowSandbox.tsx` 和 `SixStepFlowFullscreen.tsx` 中的 SVG 渲染部分:
- `buildLayout()` 的 `LayoutNode` 数组可直接转换为 G6 节点
- `LayoutEdge` 数组可直接转换为 G6 边
- `CURRENT_PAIR_BY_STAGE` 和 `EDGE_COLOR` 仍适用
### 新增推演子阶段
1. 在 `types.ts` 的 `SubStepKey` 中新增键名
2. 在 `SixStepStages` 中新增对应 `*StageData` 字段
3. 在 `mockData.ts` 的 `runSixStepDeduction` 中新增生成逻辑(若仍需要离线演示)
4. 在 `SixStepConversation.tsx` 的 `AssistantMessage` 中新增阶段渲染块
5. 在 `SixStepProgress.tsx` / **`SixStepProgressHorizontal.tsx`** 的 `SUB_STEPS` 中新增步骤项
6. 若需要流程图新层级,在 `flowLayout.ts` 的 `buildLayout` 中新增节点构建逻辑
@@ -0,0 +1,266 @@
import type {
ActorCard,
BehaviorDriverCard,
NarrativeCard,
PurposeCard,
SubStepKey,
} from './types';
export interface LayoutNode {
id: string;
level: number;
x: number;
y: number;
width: number;
height: number;
variant: 'agent' | 'purpose' | 'actor' | 'behavior' | 'driver' | 'narrative';
title: string;
detail: string;
children: string[];
parent?: string;
}
export interface LayoutEdge {
id: string;
fromId: string;
toId: string;
pair: 'agent-purpose' | 'purpose-actor' | 'actor-behavior' | 'behavior-driver' | 'driver-narrative';
}
export interface FlowLayout {
nodes: LayoutNode[];
edges: LayoutEdge[];
width: number;
height: number;
}
export interface BuildLayoutOptions {
nodeWidth?: number;
nodeHeight?: number;
levelGap?: number;
nodeGap?: number;
paddingX?: number;
paddingY?: number;
rootTitle?: string;
rootDetail?: string;
}
export const CURRENT_PAIR_BY_STAGE: Record<SubStepKey, LayoutEdge['pair'] | null> = {
purpose: 'agent-purpose',
actor: 'purpose-actor',
behavior: 'actor-behavior',
narrative: 'driver-narrative',
};
export const STATUS_TITLE: Record<SubStepKey, string> = {
purpose: '正在生成目的中',
actor: '正在生成行为体中',
behavior: '正在生成预期行为和驱动因素中',
narrative: '正在生成叙事中',
};
export const EDGE_COLOR: Record<LayoutEdge['pair'], { dark: string; light: string }> = {
'agent-purpose': { dark: '#a78bfa', light: '#fb923c' },
'purpose-actor': { dark: '#22d3ee', light: '#ef4444' },
'actor-behavior': { dark: '#34d399', light: '#10b981' },
'behavior-driver': { dark: '#5eead4', light: '#0d9488' },
'driver-narrative': { dark: '#fbbf24', light: '#f59e0b' },
};
export const EDGE_LABEL: Record<LayoutEdge['pair'], string> = {
'agent-purpose': '智能体→目的',
'purpose-actor': '目的→行为体',
'actor-behavior': '行为体→预期行为',
'behavior-driver': '预期行为→驱动因素',
'driver-narrative': '驱动因素→叙事',
};
export function buildEdgePath(from: LayoutNode, to: LayoutNode): string {
const x1 = from.x + from.width / 2;
const y1 = from.y + from.height;
const x2 = to.x + to.width / 2;
const y2 = to.y;
const my = (y1 + y2) / 2;
return `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
}
export function buildLayout(
purposes: PurposeCard[],
actors: ActorCard[],
behaviors: BehaviorDriverCard[],
narratives: NarrativeCard[],
options: BuildLayoutOptions = {},
): FlowLayout {
const NODE_WIDTH = options.nodeWidth ?? 200;
const NODE_HEIGHT = options.nodeHeight ?? 84;
const LEVEL_GAP = options.levelGap ?? 130;
const NODE_GAP = options.nodeGap ?? 28;
const PADDING_X = options.paddingX ?? 48;
const PADDING_Y = options.paddingY ?? 40;
const rootTitle = options.rootTitle?.trim() || '智能体';
const rootDetail = options.rootDetail?.trim() || '协同推演 6 步法生成内容';
const nodes: LayoutNode[] = [];
const nodeMap = new Map<string, LayoutNode>();
const push = (node: LayoutNode) => {
nodes.push(node);
nodeMap.set(node.id, node);
};
const agent: LayoutNode = {
id: 'agent-root',
level: 0,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'agent',
title: rootTitle,
detail: rootDetail,
children: purposes.map((p) => p.id),
};
push(agent);
purposes.forEach((p) => {
push({
id: p.id,
level: 1,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'purpose',
title: p.title,
detail: p.reason,
children: actors.filter((a) => a.purposeId === p.id).map((a) => a.id),
parent: 'agent-root',
});
});
actors.forEach((a) => {
push({
id: a.id,
level: 2,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'actor',
title: a.title,
detail: a.content,
children: behaviors.filter((b) => b.actorId === a.id).map((b) => `${b.id}-behavior`),
parent: a.purposeId,
});
});
behaviors.forEach((b) => {
const behaviorId = `${b.id}-behavior`;
const driverId = `${b.id}-driver`;
push({
id: behaviorId,
level: 3,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'behavior',
title: '预期行为',
detail: b.expectedBehavior,
children: [driverId],
parent: b.actorId,
});
push({
id: driverId,
level: 4,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'driver',
title: '驱动因素',
detail: b.driver,
children: narratives.filter((n) => n.driverId === b.id).map((n) => n.id),
parent: behaviorId,
});
});
narratives.forEach((n) => {
push({
id: n.id,
level: 5,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'narrative',
title: n.title,
detail: n.reason,
children: [],
parent: `${n.driverId}-driver`,
});
});
const leafCounter = { v: 0 };
const place = (node: LayoutNode) => {
if (node.children.length === 0) {
node.x = PADDING_X + leafCounter.v * (NODE_WIDTH + NODE_GAP);
leafCounter.v += 1;
return;
}
node.children.forEach((cid) => {
const child = nodeMap.get(cid);
if (child) place(child);
});
const xs = node.children.map((cid) => nodeMap.get(cid)?.x ?? 0).filter((v) => v >= 0);
if (xs.length > 0) {
node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
} else {
node.x = PADDING_X;
}
};
place(agent);
nodes.forEach((node) => {
node.y = PADDING_Y + node.level * (NODE_HEIGHT + LEVEL_GAP);
});
const edges: LayoutEdge[] = [];
nodes.forEach((node) => {
node.children.forEach((cid) => {
const child = nodeMap.get(cid);
if (!child) return;
let pair: LayoutEdge['pair'];
if (node.variant === 'agent') pair = 'agent-purpose';
else if (node.variant === 'purpose') pair = 'purpose-actor';
else if (node.variant === 'actor') pair = 'actor-behavior';
else if (node.variant === 'behavior') pair = 'behavior-driver';
else pair = 'driver-narrative';
edges.push({ id: `${node.id}->${cid}`, fromId: node.id, toId: cid, pair });
});
});
const allLevels = Math.max(...nodes.map((n) => n.level));
const maxX = Math.max(...nodes.map((n) => n.x + n.width));
const totalWidth = maxX + PADDING_X;
const totalHeight = PADDING_Y * 2 + (allLevels + 1) * NODE_HEIGHT + allLevels * LEVEL_GAP;
return { nodes, edges, width: totalWidth, height: totalHeight };
}
export const VARIANT_ABBREV: Record<LayoutNode['variant'], string> = {
agent: 'AI',
purpose: 'md',
actor: 'xwt',
behavior: 'yqxw',
driver: 'qdys',
narrative: 'xs',
};
export const VARIANT_LABEL: Record<LayoutNode['variant'], string> = {
agent: '智能体',
purpose: '目的',
actor: '行为体',
behavior: '预期行为',
driver: '驱动因素',
narrative: '叙事',
};
+346
View File
@@ -0,0 +1,346 @@
import type {
ActorCard,
BehaviorDriverCard,
MainStepKey,
NarrativeCard,
PurposeCard,
SixStepMessage,
StepStatus,
SubStepKey,
} from './types';
const PURPOSE_SEED: { title: string; reason: string }[] = [
{ title: '建立品牌权威认知', reason: '通过持续的专业内容输出,塑造行业领导者形象,使核心受众在面对同类品牌时优先想起本品牌,并带来长期信任度' },
{ title: '激活核心用户决策路径', reason: '梳理用户从兴趣触达到决策转化的关键触点,缩短决策链路、提升转化效率' },
{ title: '强化情感价值连接', reason: '通过故事化叙事激发情绪共鸣,沉淀难以被替代的品牌情感资产' },
];
const ACTOR_SEED: { title: string; content: string }[] = [
{ title: '行业意见领袖(KOL)', content: '在专业垂直领域有持续影响力,可在第一时间撬动核心圈层并带动二次扩散' },
{ title: '品牌官方账号矩阵', content: '统一对外传递品牌主张与最新动态,承担信息发布、用户互动与口碑响应' },
{ title: '深度内容创作者', content: '产出长图文、视频解读等内容形态,承担引发讨论与口碑沉淀的角色' },
{ title: '行业垂直媒体', content: '具备专业背书能力,承担议题设置与权威背书职责' },
{ title: '核心用户共创社区', content: '由忠诚用户构成,通过 UGC 形式输出真实使用反馈与场景示范' },
{ title: '行业分析机构', content: '通过研究报告与数据支撑品牌主张,加强观点的可信度' },
{ title: '品牌代言关键人', content: '与品牌价值观高度契合的代言人或合作方,承担情感投射与信任放大' },
];
const BEHAVIOR_SEED: { expectedBehavior: string; driver: string }[] = [
{ expectedBehavior: '主动转发并附带专业点评', driver: '内容契合自身专业立场,带来圈层认同与社交资本' },
{ expectedBehavior: '在专业社区发起延伸讨论', driver: '议题具备争议或洞察价值,能引发同行参与并扩散' },
{ expectedBehavior: '将品牌内容沉淀为参考资料', driver: '内容具备结构化、可复用的方法论价值,可在工作中长期引用' },
{ expectedBehavior: '主动在直播间或路演中引用品牌观点', driver: '观点具备可解释性,能帮助行为体强化自身专业可信度' },
{ expectedBehavior: '产出二次内容(长图文 / 视频)', driver: '原始素材具备可改编空间,能匹配自身受众语境' },
{ expectedBehavior: '组织线下闭门交流会', driver: '议题对核心受众具备稀缺性,符合社群运营节奏' },
{ expectedBehavior: '在调研报告中将品牌列为典型案例', driver: '品牌案例具备数据支撑、典型且可被结构化引用' },
{ expectedBehavior: '在公开演讲中引用品牌叙事框架', driver: '叙事框架具备方法论高度,可被借用以提升自身演讲含金量' },
];
const NARRATIVE_SEED: { title: string; reason: string }[] = [
{ title: '从噪音中识别真实信号', reason: '聚焦行业过度信息的痛点,主张以理性框架回归本质' },
{ title: '让专业判断回到决策中心', reason: '强调专业判断在复杂环境下的不可替代性' },
{ title: '以长期主义穿越周期', reason: '倡导关注长期价值积累,而非短期爆点' },
{ title: '把复杂问题讲简单', reason: '通过结构化表达让专业内容更易被传播与理解' },
{ title: '让每一次选择都有迹可循', reason: '强调决策可追溯、可复盘,建立专业信任' },
{ title: '与时间做朋友', reason: '把品牌成长锚定在长期视角,淡化短期波动焦虑' },
{ title: '相信专业的力量', reason: '通过行为体共同的专业身份认同强化品牌主张' },
{ title: '把方法论变成可复用的工具', reason: '将抽象主张沉淀为工具化资产,方便受众反复引用' },
];
const PURPOSE_THINKING = `根据当前问题语境,需要先厘清品牌战略推演的核心目的:
1. 综合输入素材,识别可能的目标方向;
2. 评估不同目的之间的优先级与互补性;
3. 选择最契合当前阶段的目的组合,作为后续推演基础。`;
const ACTOR_THINKING = `围绕已生成的目的,开始识别支撑该目的所需的关键行为体:
- 需要明确每个行为体的角色定位
- 评估其对目的的贡献度与覆盖范围
- 避免行为体之间能力重叠或缺位`;
const BEHAVIOR_THINKING = `针对每个行为体,推演其在场景中应当展现的预期行为:
- 行为需要可观察、可衡量
- 同时识别推动该行为发生的关键驱动因素
- 行为与驱动因素需保持因果一致性`;
const NARRATIVE_THINKING = `基于驱动因素,构建用于触发行为的叙事框架:
- 叙事需要紧扣驱动因素的情绪或理性触点
- 兼顾品牌主张与受众心智匹配度
- 形成可被复用的叙事模板`;
export interface SixStepMockController {
message: SixStepMessage;
stop: () => void;
promise: Promise<void>;
}
const TYPE_INTERVAL = 28;
const STAGE_GAP = 380;
function buildPurposeCards(): PurposeCard[] {
return PURPOSE_SEED.map((seed, idx) => ({
id: `purpose-${idx + 1}`,
index: idx + 1,
title: seed.title,
reason: seed.reason,
}));
}
function buildActorCards(purposes: PurposeCard[]): ActorCard[] {
const cards: ActorCard[] = [];
let counter = 0;
purposes.forEach((purpose, pIdx) => {
const count = pIdx === 0 ? 2 : 1;
for (let i = 0; i < count; i += 1) {
counter += 1;
const seed = ACTOR_SEED[(counter - 1) % ACTOR_SEED.length];
cards.push({
id: `actor-${counter}`,
index: counter,
purposeId: purpose.id,
title: seed.title,
content: seed.content,
});
}
});
return cards;
}
function buildBehaviorCards(actors: ActorCard[]): BehaviorDriverCard[] {
const cards: BehaviorDriverCard[] = [];
let counter = 0;
actors.forEach((actor, aIdx) => {
const count = aIdx % 2 === 0 ? 2 : 1;
for (let i = 0; i < count; i += 1) {
counter += 1;
const seed = BEHAVIOR_SEED[(counter - 1) % BEHAVIOR_SEED.length];
cards.push({
id: `behavior-${counter}`,
index: counter,
actorId: actor.id,
expectedBehavior: seed.expectedBehavior,
driver: seed.driver,
});
}
});
return cards;
}
function buildNarrativeCards(behaviors: BehaviorDriverCard[]): NarrativeCard[] {
const cards: NarrativeCard[] = [];
let counter = 0;
behaviors.forEach((behavior, bIdx) => {
const count = bIdx % 2 === 0 ? 2 : 1;
for (let i = 0; i < count; i += 1) {
counter += 1;
const seed = NARRATIVE_SEED[(counter - 1) % NARRATIVE_SEED.length];
cards.push({
id: `narrative-${counter}`,
index: counter,
driverId: behavior.id,
title: seed.title,
reason: seed.reason,
});
}
});
return cards;
}
function buildFinalAnswer(): string {
return `综合本次 6 步法推演结果,可以得到如下战略要点:
1. **目的**:以品牌权威认知为锚点,在专业人群中建立可信表达。
2. **行为体**:依靠 KOL、官方账号矩阵、深度创作者三类协同发声。
3. **预期行为与驱动因素**:通过专业内容激发主动传播与延伸讨论,形成圈层共振。
4. **叙事**:以"在噪音中识别真实信号"为核心叙事线,串联整个传播节奏。
后续可基于该推演继续生成传播节奏、渠道分工与内容资产清单。`;
}
export function buildInitialAssistantMessage(): SixStepMessage {
return {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: '',
timestamp: new Date(),
stages: {},
finalAnswer: '',
mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
};
}
interface RunOptions {
onUpdate: (message: SixStepMessage) => void;
onComplete?: () => void;
}
/**
* 模拟 6 步法推演的全过程:信息准备 → 6 步法(4 个阶段) → 最终结果。
*
* 通过分阶段写入 thinking 文本和卡片,模拟真实流式生成效果。
*/
export function runSixStepDeduction(message: SixStepMessage, options: RunOptions): SixStepMockController {
let cancelled = false;
const timers: number[] = [];
const wait = (ms: number) =>
new Promise<void>((resolve) => {
const id = window.setTimeout(() => {
resolve();
}, ms);
timers.push(id);
});
const setMain = (key: MainStepKey, status: StepStatus) => {
message.mainStepStatus = { ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }), [key]: status };
if (status === 'running') {
message.currentMainStep = key;
}
};
const setSub = (key: SubStepKey, status: StepStatus) => {
message.subStepStatus = {
...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }),
[key]: status,
};
if (status === 'running') {
message.currentSubStep = key;
}
};
const emit = () => {
if (cancelled) return;
options.onUpdate({ ...message, stages: { ...message.stages } });
};
const typeInto = async (text: string, write: (chunk: string) => void) => {
const step = 4;
for (let i = 0; i <= text.length; i += step) {
if (cancelled) return;
write(text.slice(0, i));
emit();
await wait(TYPE_INTERVAL);
}
if (!cancelled) {
write(text);
emit();
}
};
const promise = (async () => {
setMain('info', 'running');
emit();
await wait(800);
if (cancelled) return;
setMain('info', 'done');
setMain('sixstep', 'running');
emit();
await wait(STAGE_GAP);
setSub('purpose', 'running');
message.stages = { ...message.stages, purpose: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(PURPOSE_THINKING, (chunk) => {
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), thinking: chunk } };
});
if (cancelled) return;
const purposes = buildPurposeCards();
for (const card of purposes) {
if (cancelled) return;
const existing = message.stages?.purpose?.cards ?? [];
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), cards: [...existing, card] } };
emit();
await wait(280);
}
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), complete: true } };
setSub('purpose', 'done');
emit();
await wait(STAGE_GAP);
setSub('actor', 'running');
message.stages = { ...message.stages, actor: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(ACTOR_THINKING, (chunk) => {
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), thinking: chunk } };
});
if (cancelled) return;
const actors = buildActorCards(purposes);
for (const card of actors) {
if (cancelled) return;
const existing = message.stages?.actor?.cards ?? [];
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), cards: [...existing, card] } };
emit();
await wait(220);
}
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), complete: true } };
setSub('actor', 'done');
emit();
await wait(STAGE_GAP);
setSub('behavior', 'running');
message.stages = { ...message.stages, behavior: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(BEHAVIOR_THINKING, (chunk) => {
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), thinking: chunk } };
});
if (cancelled) return;
const behaviors = buildBehaviorCards(actors);
for (const card of behaviors) {
if (cancelled) return;
const existing = message.stages?.behavior?.cards ?? [];
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), cards: [...existing, card] } };
emit();
await wait(200);
}
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), complete: true } };
setSub('behavior', 'done');
emit();
await wait(STAGE_GAP);
setSub('narrative', 'running');
message.stages = { ...message.stages, narrative: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(NARRATIVE_THINKING, (chunk) => {
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), thinking: chunk } };
});
if (cancelled) return;
const narratives = buildNarrativeCards(behaviors);
for (const card of narratives) {
if (cancelled) return;
const existing = message.stages?.narrative?.cards ?? [];
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), cards: [...existing, card] } };
emit();
await wait(200);
}
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), complete: true } };
setSub('narrative', 'done');
setMain('sixstep', 'done');
setMain('final', 'running');
emit();
await wait(STAGE_GAP);
if (cancelled) return;
await typeInto(buildFinalAnswer(), (chunk) => {
message.finalAnswer = chunk;
});
if (cancelled) return;
setMain('final', 'done');
message.currentMainStep = undefined;
message.currentSubStep = undefined;
emit();
options.onComplete?.();
})();
return {
message,
stop: () => {
cancelled = true;
timers.forEach((t) => window.clearTimeout(t));
},
promise,
};
}
+179
View File
@@ -0,0 +1,179 @@
@keyframes sixstep-ant-march {
to {
stroke-dashoffset: -24;
}
}
.sixstep-ant-line {
stroke-dasharray: 6 6;
animation: sixstep-ant-march 0.7s linear infinite;
}
@keyframes sixstep-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.55);
}
50% {
box-shadow: 0 0 0 8px rgba(56, 189, 248, 0);
}
}
.sixstep-pulse {
animation: sixstep-pulse 1.6s ease-in-out infinite;
}
@keyframes sixstep-shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.sixstep-shimmer-text {
background-image: linear-gradient(
90deg,
rgba(148, 163, 184, 0.55) 0%,
rgba(248, 250, 252, 1) 45%,
rgba(148, 163, 184, 0.55) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sixstep-shimmer 2s linear infinite;
}
.sixstep-shimmer-text-light {
background-image: linear-gradient(
90deg,
rgba(220, 38, 38, 0.45) 0%,
rgba(190, 18, 60, 0.95) 45%,
rgba(220, 38, 38, 0.45) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sixstep-shimmer 2s linear infinite;
}
@keyframes sixstep-spin-soft {
to {
transform: rotate(360deg);
}
}
.sixstep-spin-soft {
animation: sixstep-spin-soft 1.4s linear infinite;
}
@keyframes sixstep-fade-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sixstep-fade-up {
animation: sixstep-fade-up 0.32s ease-out both;
}
.sixstep-resize-handle {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 6px;
cursor: col-resize;
user-select: none;
z-index: 10;
}
.sixstep-resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 36px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.4);
transition: background 0.2s;
}
.sixstep-resize-handle:hover::after {
background: rgba(56, 189, 248, 0.8);
}
@keyframes sixstep-node-pop {
0% {
opacity: 0;
transform: scale(0.4);
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
}
60% {
opacity: 1;
transform: scale(1.18);
filter: drop-shadow(0 0 12px rgba(56, 189, 248, 0.8));
}
100% {
opacity: 1;
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
}
}
.sixstep-node-pop {
transform-box: fill-box;
transform-origin: center;
animation: sixstep-node-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes sixstep-ring-burst {
0% {
opacity: 0.85;
r: 0;
stroke-width: 4;
}
100% {
opacity: 0;
r: 60;
stroke-width: 0.5;
}
}
.sixstep-ring-burst {
animation: sixstep-ring-burst 0.7s ease-out forwards;
fill: none;
}
.sixstep-canvas-grab {
cursor: grab;
}
.sixstep-canvas-grabbing {
cursor: grabbing;
}
.sixstep-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
transition: background 0.2s, color 0.2s;
}
.sixstep-minimap-rect {
fill: rgba(56, 189, 248, 0.18);
stroke: rgba(56, 189, 248, 0.9);
stroke-width: 1;
}
+161
View File
@@ -0,0 +1,161 @@
export type MainStepKey = 'info' | 'sixstep' | 'final';
export type SubStepKey = 'purpose' | 'actor' | 'behavior' | 'narrative';
export type StepStatus = 'pending' | 'running' | 'done';
export interface PurposeCard {
id: string;
index: number;
title: string;
reason: string;
}
export interface ActorCard {
id: string;
index: number;
purposeId: string;
title: string;
content: string;
}
export interface BehaviorDriverCard {
id: string;
index: number;
actorId: string;
expectedBehavior: string;
driver: string;
}
export interface NarrativeCard {
id: string;
index: number;
driverId: string;
title: string;
reason: string;
}
export interface PurposeStageData {
thinking: string;
cards: PurposeCard[];
complete: boolean;
}
export interface ActorStageData {
thinking: string;
cards: ActorCard[];
complete: boolean;
}
export interface BehaviorStageData {
thinking: string;
cards: BehaviorDriverCard[];
complete: boolean;
}
export interface NarrativeStageData {
thinking: string;
cards: NarrativeCard[];
complete: boolean;
}
export interface InfoStageData {
content: string;
complete: boolean;
}
export interface SixStepStages {
info?: InfoStageData;
purpose?: PurposeStageData;
actor?: ActorStageData;
behavior?: BehaviorStageData;
narrative?: NarrativeStageData;
}
export type SelectableMcpKey = 'purpose' | 'actor' | 'behavior_factor' | 'narrative';
export interface ExpertCard {
id: string;
backendId?: string;
name: string;
detail: string;
avatarUrl?: string;
title?: string;
tags?: string[];
isCustom?: boolean;
}
export interface ExpertMatchData {
status: 'idle' | 'searching' | 'creating' | 'ready' | 'confirmed' | 'error';
thinking: string;
experts: ExpertCard[];
selectedExpertId?: string;
selectedMcps?: SelectableMcpKey[];
errorMessage?: string;
searchedAt?: number;
}
export type SixStepPhase =
| 'idle'
| 'intent-detecting'
| 'expert-match'
| 'expert-confirm'
| 'generate'
| 'paused'
| 'completed'
| 'clarifying'
| 'follow-up';
/** SSE error_log / error 事件归一后的错误记录,积累在 SixStepMessage.errors 中。 */
export interface StreamErrorItem {
errorId: string;
taskId?: string;
level: 'warning' | 'error';
source: 'orchestrator' | 'mcp' | 'skill' | 'llm' | 'network';
mcpKey?: string;
stepName?: string;
skillKey?: string;
skillName?: string;
message: string;
recoverable?: boolean;
createdAt: number;
}
/** 后端 step_end 的原始字段,用于 continue_analysis 时回传 last_steps */
export interface BackendStepResult {
key: string;
name?: string;
status: string;
thinking?: string;
structured_outputs?: Record<string, unknown>[];
markdown?: string;
references?: Record<string, unknown>[];
}
export interface SixStepMessage {
id: string;
role: 'user' | 'assistant';
content?: string;
timestamp: Date;
outputFormat?: 'structured' | 'markdown';
markdownContent?: string;
stages?: SixStepStages;
finalAnswer?: string;
mainStepStatus?: Record<MainStepKey, StepStatus>;
subStepStatus?: Record<SubStepKey, StepStatus>;
currentSubStep?: SubStepKey;
currentMainStep?: MainStepKey;
phase?: SixStepPhase;
expertMatch?: ExpertMatchData;
selectedExpert?: ExpertCard;
selectedMcps?: SelectableMcpKey[];
intent?: { name: string; confidence?: number; reason?: string };
clarifyQuestion?: string;
/** 后端原始步骤数据,paused 后继续时作为 last_steps 传回后端 */
rawSteps?: BackendStepResult[];
/** 本轮是从哪个步骤继续的(resume/rerun 事件携带),供 UI 显示"继续自…" */
resumedFrom?: string;
/** 实际用于分析的问题(revise_input_continue 后更新为新问题) */
effectiveUserInput?: string;
/** 本轮累积的错误/警告列表(error_log 与 error 事件均追加于此) */
errors?: StreamErrorItem[];
}