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 {
|
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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据卡片类型找到对应的实际模型ID(Claude)
|
||||||
|
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);
|
// 根据卡片类型找到对应的实际模型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 (
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user