234 lines
9.1 KiB
TypeScript
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;
|