Download src/components/settings/ModelSettings.tsx from Leon4gr45/Velo: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/settings/ModelSettings.tsx
- Command line
-
hf download hf://spaces/Leon4gr45/Velo/src/components/settings/ModelSettings.tsx
-
curl -L -o ModelSettings.tsx https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/settings/ModelSettings.tsx
13.1 kB
| import { useState, useEffect } from 'react' | |
| import { Plus, Trash2, Check, Zap, Save, Globe } from 'lucide-react' | |
| import { useTranslation } from 'react-i18next' | |
| import { useLLMStore } from '../../stores/llm-store' | |
| import type { ModelProfile } from '../../shared/ipc-channels' | |
| import { randomUUID } from '../../utils/id' | |
| import { Button } from '../ui/Button' | |
| import { Input } from '../ui/Input' | |
| import { Label } from '../ui/Label' | |
| import { NativeSelect } from '../ui/NativeSelect' | |
| import { cn } from '../../lib/utils' | |
| /** 模型设置面板 — 在侧边栏 settings 视图中展示 */ | |
| export default function ModelSettings() { | |
| const { t } = useTranslation('settings') | |
| const models = useLLMStore(s => s.models) | |
| const defaultModelId = useLLMStore(s => s.defaultModelId) | |
| const loaded = useLLMStore(s => s.loaded) | |
| const loadModels = useLLMStore(s => s.loadModels) | |
| const saveModel = useLLMStore(s => s.saveModel) | |
| const deleteModel = useLLMStore(s => s.deleteModel) | |
| const setDefaultModel = useLLMStore(s => s.setDefaultModel) | |
| const [editingModel, setEditingModel] = useState<ModelProfile | null>(null) | |
| const [saving, setSaving] = useState(false) | |
| useEffect(() => { | |
| if (!loaded) loadModels() | |
| }, [loaded, loadModels]) | |
| /** 创建新模型配置 */ | |
| const handleAddModel = () => { | |
| setEditingModel({ | |
| id: randomUUID(), | |
| name: '', | |
| provider: 'openai', | |
| protocol: 'openai', | |
| modelName: 'gpt-4o', | |
| apiKey: '', | |
| baseUrl: 'https://api.openai.com', | |
| temperature: 0.7, | |
| maxTokens: 4096, | |
| purposes: ['generation'], | |
| }) | |
| } | |
| /** 保存模型 */ | |
| const handleSave = async () => { | |
| if (!editingModel) return | |
| setSaving(true) | |
| await saveModel(editingModel) | |
| setEditingModel(null) | |
| setSaving(false) | |
| } | |
| return ( | |
| <div className="h-full flex flex-col overflow-hidden"> | |
| {/* 标题栏 */} | |
| <div className="panel-header flex items-center justify-between"> | |
| <span>{t('models.title')}</span> | |
| <Button variant="ghost" size="icon" onClick={handleAddModel} title={t('models.addModel', { label: '' })}> | |
| <Plus size={16} /> | |
| </Button> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-3 space-y-2"> | |
| {/* 编辑表单 */} | |
| {editingModel && ( | |
| <ModelForm | |
| model={editingModel} | |
| onChange={setEditingModel} | |
| onSave={handleSave} | |
| onCancel={() => setEditingModel(null)} | |
| saving={saving} | |
| /> | |
| )} | |
| {/* 空状态 */} | |
| {!editingModel && models.length === 0 && ( | |
| <div className="flex flex-col items-center justify-center py-12 gap-3 opacity-40"> | |
| <Zap size={32} /> | |
| <span className="text-sm">{t('models.noModels', { label: '' })}</span> | |
| <Button onClick={handleAddModel} size="sm">{t('models.addFirstModel', { label: '' })}</Button> | |
| </div> | |
| )} | |
| {/* 模型列表 */} | |
| {!editingModel && models.map((model) => ( | |
| <div | |
| key={model.id} | |
| className={cn( | |
| 'p-3 rounded-lg cursor-pointer group bg-[var(--color-panel)] border', | |
| defaultModelId === model.id ? 'border-[var(--color-accent)]' : 'border-[var(--color-border)]' | |
| )} | |
| > | |
| <div className="flex items-center justify-between mb-1"> | |
| <span className="font-medium text-sm text-[var(--color-text)]"> | |
| {model.name || model.modelName} | |
| </span> | |
| <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| {defaultModelId !== model.id && ( | |
| <Button variant="ghost" size="icon" onClick={() => setDefaultModel(model.id)} title={t('models.setDefault')}> | |
| <Check size={14} /> | |
| </Button> | |
| )} | |
| <Button variant="ghost" size="icon" onClick={() => setEditingModel({ ...model })} title={t('models.editModel')}> | |
| <Save size={14} /> | |
| </Button> | |
| <Button variant="ghost" size="icon" onClick={() => deleteModel(model.id)} title={t('models.removeModel')} className="hover:text-red-400"> | |
| <Trash2 size={14} /> | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="text-xs text-[var(--color-text-muted)]"> | |
| {model.provider} · {model.modelName} | |
| {defaultModelId === model.id && ( | |
| <span className="ml-2 px-1.5 py-0.5 rounded text-[0.7rem] bg-[var(--color-accent)] text-white"> | |
| {t('models.default')} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| {/* 代理配置 */} | |
| {!editingModel && ( | |
| <ProxySettings /> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| /** 模型编辑表单 */ | |
| function ModelForm({ | |
| model, | |
| onChange, | |
| onSave, | |
| onCancel, | |
| saving, | |
| }: { | |
| model: ModelProfile | |
| onChange: (m: ModelProfile) => void | |
| onSave: () => void | |
| onCancel: () => void | |
| saving: boolean | |
| }) { | |
| const { t } = useTranslation('settings') | |
| const testConnection = useLLMStore(s => s.testConnection) | |
| const [testing, setTesting] = useState(false) | |
| const [testResult, setTestResult] = useState<{ success: boolean, error?: string } | null>(null) | |
| const update = <K extends keyof ModelProfile>(key: K, value: ModelProfile[K]) => { | |
| onChange({ ...model, [key]: value }) | |
| } | |
| const handleTest = async () => { | |
| setTesting(true) | |
| setTestResult(null) | |
| const result = await testConnection(model) | |
| setTestResult(result) | |
| setTesting(false) | |
| setTimeout(() => setTestResult(null), 3000) | |
| } | |
| return ( | |
| <div className="p-3 rounded-lg space-y-3 bg-[var(--color-panel)] border border-[var(--color-accent)]"> | |
| <div> | |
| <Label>{t('models.modelName')}</Label> | |
| <Input value={model.name} onChange={(e) => update('name', e.target.value)} placeholder={t('models.modelNamePlaceholder')} /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <div> | |
| <Label>{t('models.provider')}</Label> | |
| <NativeSelect value={model.provider} onChange={(e) => update('provider', e.target.value as ModelProfile['provider'])}> | |
| <option value="openai">OpenAI</option> | |
| <option value="deepseek">DeepSeek</option> | |
| <option value="gemini">Gemini</option> | |
| <option value="ollama">Ollama</option> | |
| <option value="custom">{t('models.custom')}</option> | |
| </NativeSelect> | |
| </div> | |
| <div> | |
| <Label>{t('models.protocol')}</Label> | |
| <NativeSelect value={model.protocol} onChange={(e) => update('protocol', e.target.value as ModelProfile['protocol'])}> | |
| <option value="openai">{t('models.protocolOpenAI')}</option> | |
| <option value="gemini">Gemini</option> | |
| </NativeSelect> | |
| </div> | |
| </div> | |
| <div> | |
| <Label>{t('models.modelName')}</Label> | |
| <Input value={model.modelName} onChange={(e) => update('modelName', e.target.value)} placeholder="gpt-4o / deepseek-chat" /> | |
| </div> | |
| <div> | |
| <Label>{t('models.baseUrl')}</Label> | |
| <Input value={model.baseUrl} onChange={(e) => update('baseUrl', e.target.value)} placeholder="https://api.openai.com" /> | |
| </div> | |
| <div> | |
| <Label>{t('models.apiKey')}</Label> | |
| <Input type="password" value={model.apiKey} onChange={(e) => update('apiKey', e.target.value)} placeholder="sk-..." /> | |
| </div> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <div> | |
| <Label>{t('models.temperature')}</Label> | |
| <Input | |
| value={String(model.temperature)} | |
| onChange={(e) => update('temperature', (e.target.value === '' ? '' : parseFloat(e.target.value)) as number)} | |
| onBlur={() => { | |
| const v = Number(model.temperature); | |
| if (isNaN(v)) update('temperature', 0.7); | |
| }} | |
| /> | |
| </div> | |
| <div> | |
| <Label>{t('models.maxTokens')}</Label> | |
| <Input | |
| value={String(model.maxTokens)} | |
| onChange={(e) => update('maxTokens', (e.target.value === '' ? '' : parseInt(e.target.value)) as number)} | |
| onBlur={() => { | |
| const v = Number(model.maxTokens); | |
| if (!v || v < 1) update('maxTokens', 4096); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| {/* 操作按钮 */} | |
| <div className="flex items-center gap-2 pt-1"> | |
| <Button | |
| variant="outline" | |
| onClick={handleTest} | |
| disabled={testing || !model.baseUrl || (!model.apiKey && model.provider !== 'ollama')} | |
| > | |
| <Zap size={13} /> | |
| {testing ? t('models.testing') : t('models.testConnection')} | |
| </Button> | |
| <Button | |
| className="flex-1" | |
| onClick={onSave} | |
| disabled={saving || !model.name || (!model.apiKey && model.provider !== 'ollama')} | |
| > | |
| <Save size={13} /> | |
| {saving ? t('models.saving') : t('models.save')} | |
| </Button> | |
| <Button variant="ghost" onClick={onCancel}>{t('draftEditor.cancel')}</Button> | |
| </div> | |
| {testResult && ( | |
| <div className={`text-xs p-2 rounded ${testResult.success ? 'bg-green-500/10 text-green-500 border border-green-500/20' : 'bg-red-500/10 text-red-500 border border-red-500/20'} break-all`}> | |
| {testResult.success ? t('models.connectionSuccess') : t('models.connectionFailed', { error: testResult.error })} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** 代理配置面板 */ | |
| function ProxySettings() { | |
| const { t } = useTranslation('settings') | |
| const [proxy, setProxy] = useState<{ | |
| enabled: boolean; type: 'http' | 'socks5'; host: string; port: number | |
| }>({ enabled: false, type: 'http', host: '', port: 7890 }) | |
| const [saving, setSaving] = useState(false) | |
| useEffect(() => { | |
| loadProxy() | |
| }, []) | |
| const loadProxy = async () => { | |
| try { | |
| const { ipc } = await import('../../services/ipc-client') | |
| const config = await ipc.invoke('config:get') | |
| if (config.proxy) setProxy(config.proxy) | |
| } catch { /* 忽略 */ } | |
| } | |
| const handleSave = async () => { | |
| setSaving(true) | |
| try { | |
| const { ipc } = await import('../../services/ipc-client') | |
| await ipc.invoke('config:set', { proxy }) | |
| } catch { /* 忽略 */ } | |
| setSaving(false) | |
| } | |
| return ( | |
| <div className="mt-4 p-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-panel)]"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <span className="text-xs font-medium text-[var(--color-text)] flex items-center gap-1"> | |
| <Globe size={13} /> {t('models.proxy.title')} | |
| </span> | |
| <label className="flex items-center gap-1.5 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={proxy.enabled} | |
| onChange={(e) => setProxy({ ...proxy, enabled: e.target.checked })} | |
| className="rounded" | |
| /> | |
| <span className="text-[0.7rem] text-[var(--color-text-muted)]"> | |
| {proxy.enabled ? t('models.proxy.enabled') : t('models.proxy.disabled')} | |
| </span> | |
| </label> | |
| </div> | |
| {proxy.enabled && ( | |
| <div className="space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <Label className="text-[0.7rem] w-12 flex-shrink-0">{t('models.proxy.type')}</Label> | |
| <NativeSelect | |
| value={proxy.type} | |
| onChange={(e) => setProxy({ ...proxy, type: e.target.value as 'http' | 'socks5' })} | |
| className="h-7 text-xs" | |
| > | |
| <option value="http">HTTP</option> | |
| <option value="socks5">SOCKS5</option> | |
| </NativeSelect> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Label className="text-[0.7rem] w-12 flex-shrink-0">{t('models.proxy.host')}</Label> | |
| <Input | |
| className="h-7 text-xs flex-1" | |
| value={proxy.host} | |
| onChange={(e) => setProxy({ ...proxy, host: e.target.value })} | |
| placeholder="127.0.0.1" | |
| /> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Label className="text-[0.7rem] w-12 flex-shrink-0">{t('models.proxy.port')}</Label> | |
| <Input | |
| className="h-7 text-xs w-24" | |
| type="number" | |
| value={proxy.port} | |
| onChange={(e) => setProxy({ ...proxy, port: (e.target.value === '' ? '' : parseInt(e.target.value)) as number })} | |
| onBlur={() => { | |
| const v = Number(proxy.port); | |
| if (!v) setProxy({ ...proxy, port: 7890 }); | |
| }} | |
| /> | |
| </div> | |
| <Button variant="outline" size="sm" onClick={handleSave} disabled={saving} className="w-full mt-2"> | |
| <Save size={12} /> {saving ? t('models.saving') : t('models.proxy.saveProxy')} | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |