代码修改
This commit is contained in:
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
@@ -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: '叙事',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in new issue
Block a user