claude-code-cchui/src/components/ui/ModelCardSelector.tsx
gaoziman 844df69b7c feat(组件): 模型选择器支持多模型类型
- ModelSelector 组件支持可选的 selectedModel
- ModelCardSelector 新增 Claude 和 Codex 模型分组展示
- 添加模型类型切换提示,提醒用户不同模型的功能差异
- 优化模型选择交互体验
2025-12-20 01:04:42 +08:00

277 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;
};
// 根据卡片类型找到对应的实际模型IDClaude
const findClaudeModelIdByCard = (cardId: string): string => {
const model = models.find((m) =>
m.modelId.toLowerCase().includes(cardId)
);
return model?.modelId || 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 (
<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>
);
}