feat(组件): 模型选择器支持多模型类型
- ModelSelector 组件支持可选的 selectedModel - ModelCardSelector 新增 Claude 和 Codex 模型分组展示 - 添加模型类型切换提示,提醒用户不同模型的功能差异 - 优化模型选择交互体验
This commit is contained in:
parent
da19858c2d
commit
844df69b7c
@ -7,7 +7,7 @@ import type { Model } from '@/types';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: Model[];
|
||||
selectedModel: Model;
|
||||
selectedModel?: Model;
|
||||
onSelect: (model: Model) => void;
|
||||
}
|
||||
|
||||
@ -15,6 +15,9 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 如果没有 selectedModel 或 models 为空,使用第一个模型或显示占位符
|
||||
const currentModel = selectedModel || models[0];
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@ -27,6 +30,15 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
|
||||
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 (
|
||||
<div className={cn('relative', isOpen && 'model-selector--open')} ref={dropdownRef}>
|
||||
{/* 触发按钮 */}
|
||||
@ -34,7 +46,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
|
||||
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"
|
||||
>
|
||||
<span>{selectedModel.name}</span>
|
||||
<span>{currentModel.name}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={cn('transition-transform duration-150', isOpen && 'rotate-180')}
|
||||
@ -52,7 +64,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
|
||||
)}
|
||||
>
|
||||
{models.map((model) => {
|
||||
const isSelected = model.id === selectedModel.id;
|
||||
const isSelected = model.id === currentModel.id;
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
@ -68,7 +80,7 @@ export function ModelSelector({ models, selectedModel, onSelect }: ModelSelector
|
||||
isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--color-text-primary)]'
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">{model.tag}</span>
|
||||
</button>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
// 模型卡片配置
|
||||
const MODEL_CARDS = [
|
||||
// Claude 模型卡片配置
|
||||
const CLAUDE_MODEL_CARDS = [
|
||||
{
|
||||
id: '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 {
|
||||
value: string;
|
||||
onChange: (modelId: string) => void;
|
||||
disabled?: boolean;
|
||||
models?: { modelId: string; displayName: string }[];
|
||||
models?: { modelId: string; displayName: string; modelType?: string }[];
|
||||
}
|
||||
|
||||
export function ModelCardSelector({
|
||||
@ -37,36 +64,111 @@ export function ModelCardSelector({
|
||||
disabled = false,
|
||||
models = [],
|
||||
}: 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();
|
||||
if (lowerModelId.includes('haiku')) return 'haiku';
|
||||
if (lowerModelId.includes('opus')) return 'opus';
|
||||
return 'sonnet'; // 默认 sonnet
|
||||
if (lowerModelId.includes('sonnet')) return 'sonnet';
|
||||
return null;
|
||||
};
|
||||
|
||||
// 根据卡片类型找到对应的实际模型ID
|
||||
const findModelIdByCard = (cardId: string): string => {
|
||||
// 根据当前选中的模型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;
|
||||
};
|
||||
|
||||
const selectedCard = getSelectedCard(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">
|
||||
{MODEL_CARDS.map((card) => {
|
||||
const isSelected = selectedCard === card.id;
|
||||
{CLAUDE_MODEL_CARDS.map((card) => {
|
||||
const isSelected = selectedClaudeCard === card.id;
|
||||
return (
|
||||
<button
|
||||
key={card.id}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
const newModelId = findModelIdByCard(card.id);
|
||||
onChange(newModelId);
|
||||
}
|
||||
const newModelId = findClaudeModelIdByCard(card.id);
|
||||
handleModelSelect(newModelId, 'claude');
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
@ -104,5 +206,71 @@ export function ModelCardSelector({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user