- ModelSelector 组件支持可选的 selectedModel - ModelCardSelector 新增 Claude 和 Codex 模型分组展示 - 添加模型类型切换提示,提醒用户不同模型的功能差异 - 优化模型选择交互体验
277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
'use client';
|
||
|
||
import { cn } from '@/lib/utils';
|
||
import { useState, useEffect } from 'react';
|
||
import { AlertCircle } from 'lucide-react';
|
||
|
||
// Claude 模型卡片配置
|
||
const CLAUDE_MODEL_CARDS = [
|
||
{
|
||
id: 'haiku',
|
||
name: 'Haiku',
|
||
description: 'Fast & efficient',
|
||
modelIdPattern: 'haiku',
|
||
},
|
||
{
|
||
id: 'sonnet',
|
||
name: 'Sonnet',
|
||
description: 'Balanced',
|
||
modelIdPattern: 'sonnet',
|
||
},
|
||
{
|
||
id: 'opus',
|
||
name: 'Opus',
|
||
description: 'Most capable',
|
||
modelIdPattern: 'opus',
|
||
},
|
||
];
|
||
|
||
// 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 {
|
||
value: string;
|
||
onChange: (modelId: string) => void;
|
||
disabled?: boolean;
|
||
models?: { modelId: string; displayName: string; modelType?: string }[];
|
||
}
|
||
|
||
export function ModelCardSelector({
|
||
value,
|
||
onChange,
|
||
disabled = false,
|
||
models = [],
|
||
}: ModelCardSelectorProps) {
|
||
// 当前选中的模型类型
|
||
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();
|
||
if (lowerModelId.includes('haiku')) return 'haiku';
|
||
if (lowerModelId.includes('opus')) return 'opus';
|
||
if (lowerModelId.includes('sonnet')) return 'sonnet';
|
||
return null;
|
||
};
|
||
|
||
// 根据当前选中的模型ID判断选中的卡片(Codex)
|
||
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;
|
||
};
|
||
|
||
// 根据卡片类型找到对应的实际模型ID(Claude)
|
||
const findClaudeModelIdByCard = (cardId: string): string => {
|
||
const model = models.find((m) =>
|
||
m.modelId.toLowerCase().includes(cardId)
|
||
);
|
||
return model?.modelId || value;
|
||
};
|
||
|
||
// 根据卡片类型找到对应的实际模型ID(Codex)
|
||
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 (
|
||
<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">
|
||
{CLAUDE_MODEL_CARDS.map((card) => {
|
||
const isSelected = selectedClaudeCard === card.id;
|
||
return (
|
||
<button
|
||
key={card.id}
|
||
onClick={() => {
|
||
const newModelId = findClaudeModelIdByCard(card.id);
|
||
handleModelSelect(newModelId, 'claude');
|
||
}}
|
||
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-[var(--color-primary)]',
|
||
isSelected
|
||
? 'bg-[var(--color-primary-light)] border-[var(--color-primary)]'
|
||
: 'bg-[var(--color-bg-tertiary)] border-transparent hover:border-[var(--color-border)]',
|
||
disabled && 'opacity-50 cursor-not-allowed'
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'text-sm font-semibold',
|
||
isSelected
|
||
? 'text-[var(--color-primary)]'
|
||
: 'text-[var(--color-text-primary)]'
|
||
)}
|
||
>
|
||
{card.name}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
'text-xs mt-1',
|
||
isSelected
|
||
? 'text-[var(--color-primary)]'
|
||
: 'text-[var(--color-text-tertiary)]'
|
||
)}
|
||
>
|
||
{card.description}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</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>
|
||
);
|
||
}
|