Files
deerflow-code/web-code/components/angentchat/McpSelectModal.tsx
T
2026-05-28 18:29:37 +08:00

234 lines
9.1 KiB
TypeScript

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;