feat(组件): 模型选择器支持多模型类型

- ModelSelector 组件支持可选的 selectedModel
- ModelCardSelector 新增 Claude 和 Codex 模型分组展示
- 添加模型类型切换提示,提醒用户不同模型的功能差异
- 优化模型选择交互体验
This commit is contained in:
gaoziman 2025-12-20 01:04:42 +08:00
parent da19858c2d
commit 844df69b7c
2 changed files with 239 additions and 59 deletions

View File

@ -7,7 +7,7 @@ import type { Model } from '@/types';
interface ModelSelectorProps { interface ModelSelectorProps {
models: Model[]; models: Model[];
selectedModel: Model; selectedModel?: Model;
onSelect: (model: Model) => void; onSelect: (model: Model) => void;
} }
@ -15,6 +15,9 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// 如果没有 selectedModel 或 models 为空,使用第一个模型或显示占位符
const currentModel = selectedModel || models[0];
// 点击外部关闭 // 点击外部关闭
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@ -27,6 +30,15 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
// 如果没有任何模型数据,显示加载状态
if (!currentModel || models.length === 0) {
return (
<div className="flex items-center gap-1 px-2 py-1 text-sm font-medium text-[var(--color-text-tertiary)]">
<span>...</span>
</div>
);
}
return ( return (
<div className={cn('relative', isOpen && 'model-selector--open')} ref={dropdownRef}> <div className={cn('relative', isOpen && 'model-selector--open')} ref={dropdownRef}>
{/* 触发按钮 */} {/* 触发按钮 */}
@ -34,7 +46,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 px-2 py-1 text-sm font-medium text-[var(--color-text-secondary)] rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors" className="flex items-center gap-1 px-2 py-1 text-sm font-medium text-[var(--color-text-secondary)] rounded-lg hover:bg-[var(--color-bg-hover)] transition-colors"
> >
<span>{selectedModel.name}</span> <span>{currentModel.name}</span>
<ChevronDown <ChevronDown
size={16} size={16}
className={cn('transition-transform duration-150', isOpen && 'rotate-180')} className={cn('transition-transform duration-150', isOpen && 'rotate-180')}
@ -52,7 +64,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
)} )}
> >
{models.map((model) => { {models.map((model) => {
const isSelected = model.id === selectedModel.id; const isSelected = model.id === currentModel.id;
return ( return (
<button <button
key={model.id} key={model.id}
@ -68,7 +80,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-primary)]' isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-primary)]'
)} )}
> >
{model.name} {model.displayName}
</span> </span>
<span className="text-xs text-[var(--color-text-tertiary)]">{model.tag}</span> <span className="text-xs text-[var(--color-text-tertiary)]">{model.tag}</span>
</button> </button>

View File

@ -1,9 +1,11 @@
'use client'; 'use client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
import { AlertCircle } from 'lucide-react';
// 模型卡片配置 // Claude 模型卡片配置
const MODEL_CARDS = [ const CLAUDE_MODEL_CARDS = [
{ {
id: 'haiku', id: 'haiku',
name: 'Haiku', name: 'Haiku',
@ -24,11 +26,36 @@ const MODEL_CARDS = [
}, },
]; ];
// Codex 模型卡片配置
const CODEX_MODEL_CARDS = [
{
id: 'gpt-5.1-codex',
name: 'Codex',
description: 'Standard',
modelIdPattern: 'gpt-5.1-codex',
},
{
id: 'gpt-5.1-codex-max',
name: 'Codex Max',
description: 'Enhanced',
modelIdPattern: 'gpt-5.1-codex-max',
},
{
id: 'gpt-5.2-codex',
name: 'Codex Pro',
description: 'Latest',
modelIdPattern: 'gpt-5.2-codex',
},
];
// 模型类型
type ModelType = 'claude' | 'codex';
interface ModelCardSelectorProps { interface ModelCardSelectorProps {
value: string; value: string;
onChange: (modelId: string) => void; onChange: (modelId: string) => void;
disabled?: boolean; disabled?: boolean;
models?: { modelId: string; displayName: string }[]; models?: { modelId: string; displayName: string; modelType?: string }[];
} }
export function ModelCardSelector({ export function ModelCardSelector({
@ -37,36 +64,111 @@ export function ModelCardSelector({
disabled = false, disabled = false,
models = [], models = [],
}: ModelCardSelectorProps) { }: ModelCardSelectorProps) {
// 根据当前选中的模型ID判断选中的卡片 // 当前选中的模型类型
const getSelectedCard = (modelId: string): string => { const [showModelChangeHint, setShowModelChangeHint] = useState(false);
const [previousModelType, setPreviousModelType] = useState<ModelType | null>(null);
// 判断模型类型
const getModelType = (modelId: string): ModelType => {
if (modelId.startsWith('gpt-') && modelId.includes('codex')) {
return 'codex';
}
return 'claude';
};
// 根据当前选中的模型ID判断选中的卡片Claude
const getSelectedClaudeCard = (modelId: string): string | null => {
const lowerModelId = modelId.toLowerCase(); const lowerModelId = modelId.toLowerCase();
if (lowerModelId.includes('haiku')) return 'haiku'; if (lowerModelId.includes('haiku')) return 'haiku';
if (lowerModelId.includes('opus')) return 'opus'; if (lowerModelId.includes('opus')) return 'opus';
return 'sonnet'; // 默认 sonnet if (lowerModelId.includes('sonnet')) return 'sonnet';
return null;
}; };
// 根据卡片类型找到对应的实际模型ID // 根据当前选中的模型ID判断选中的卡片Codex
const findModelIdByCard = (cardId: string): string => { const getSelectedCodexCard = (modelId: string): string | null => {
if (modelId === 'gpt-5.1-codex') return 'gpt-5.1-codex';
if (modelId === 'gpt-5.1-codex-max') return 'gpt-5.1-codex-max';
if (modelId === 'gpt-5.2-codex') return 'gpt-5.2-codex';
return null;
};
// 根据卡片类型找到对应的实际模型IDClaude
const findClaudeModelIdByCard = (cardId: string): string => {
const model = models.find((m) => const model = models.find((m) =>
m.modelId.toLowerCase().includes(cardId) m.modelId.toLowerCase().includes(cardId)
); );
return model?.modelId || value; return model?.modelId || value;
}; };
const selectedCard = getSelectedCard(value); // 根据卡片类型找到对应的实际模型IDCodex
const findCodexModelIdByCard = (cardId: string): string => {
const model = models.find((m) => m.modelId === cardId);
return model?.modelId || cardId;
};
const currentModelType = getModelType(value);
const selectedClaudeCard = getSelectedClaudeCard(value);
const selectedCodexCard = getSelectedCodexCard(value);
// 处理模型选择
const handleModelSelect = (modelId: string, modelType: ModelType) => {
if (disabled) return;
const newModelType = modelType;
// 检测模型类型切换
if (previousModelType && previousModelType !== newModelType) {
setShowModelChangeHint(true);
setTimeout(() => setShowModelChangeHint(false), 5000);
}
setPreviousModelType(newModelType);
onChange(modelId);
};
// 初始化时设置 previousModelType
useEffect(() => {
if (value && !previousModelType) {
setPreviousModelType(getModelType(value));
}
}, [value, previousModelType]);
// 检查是否有 Codex 模型可用
const hasCodexModels = models.some(m => m.modelType === 'codex' || (m.modelId.startsWith('gpt-') && m.modelId.includes('codex')));
return ( return (
<div className="space-y-6">
{/* 模型切换提示 */}
{showModelChangeHint && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200">
<AlertCircle size={18} className="mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium"></p>
<p className="text-amber-600 dark:text-amber-300 mt-1">
{currentModelType === 'codex'
? 'Codex 模型不支持思考模式Thinking但支持工具调用。'
: 'Claude 模型支持思考模式和工具调用。'}
</p>
</div>
</div>
)}
{/* Claude 模型组 */}
<div>
<div className="text-xs font-medium text-[var(--color-text-tertiary)] mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[var(--color-primary)]"></span>
Claude
</div>
<div className="flex gap-3"> <div className="flex gap-3">
{MODEL_CARDS.map((card) => { {CLAUDE_MODEL_CARDS.map((card) => {
const isSelected = selectedCard === card.id; const isSelected = selectedClaudeCard === card.id;
return ( return (
<button <button
key={card.id} key={card.id}
onClick={() => { onClick={() => {
if (!disabled) { const newModelId = findClaudeModelIdByCard(card.id);
const newModelId = findModelIdByCard(card.id); handleModelSelect(newModelId, 'claude');
onChange(newModelId);
}
}} }}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
@ -104,5 +206,71 @@ export function ModelCardSelector({
); );
})} })}
</div> </div>
</div>
{/* Codex 模型组 */}
{hasCodexModels && (
<div>
<div className="text-xs font-medium text-[var(--color-text-tertiary)] mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
Codex
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
OpenAI
</span>
</div>
<div className="flex gap-3">
{CODEX_MODEL_CARDS.map((card) => {
const isSelected = selectedCodexCard === card.id;
// 检查该模型是否在可用模型列表中
const isAvailable = models.some(m => m.modelId === card.id);
if (!isAvailable) return null;
return (
<button
key={card.id}
onClick={() => {
const newModelId = findCodexModelIdByCard(card.id);
handleModelSelect(newModelId, 'codex');
}}
disabled={disabled}
className={cn(
'flex flex-col items-center justify-center',
'w-[120px] h-[72px] rounded-md',
'border-2 transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500',
isSelected
? 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-500'
: 'bg-[var(--color-bg-tertiary)] border-transparent hover:border-emerald-300 dark:hover:border-emerald-700',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<span
className={cn(
'text-sm font-semibold',
isSelected
? 'text-emerald-600 dark:text-emerald-400'
: 'text-[var(--color-text-primary)]'
)}
>
{card.name}
</span>
<span
className={cn(
'text-xs mt-1',
isSelected
? 'text-emerald-500 dark:text-emerald-400'
: 'text-[var(--color-text-tertiary)]'
)}
>
{card.description}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
); );
} }