1 line
62 KiB
JSON
1 line
62 KiB
JSON
{"repo": "352727664/codexbridge", "files": {"webui/src/pages/ProviderForm.jsx": {"ad38e3f5e781aa726e6a36e681f4a25c05493081": {"t": 1785595171.932739, "n": 19377, "h": "dde539c81afe7228", "c": "import { useState, useEffect } from 'react'\nimport { api } from '../lib/api'\nimport LanguageSwitcher from '../components/LanguageSwitcher'\nimport { useTranslation } from '../i18n'\n\nconst PRESETS = [\n { id: 'deepseek', name: 'DeepSeek', base_url: 'https://api.deepseek.com', model: 'deepseek-chat', has_reasoning: true },\n { id: 'qwen', name: 'Qwen', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-max', has_reasoning: true },\n { id: 'kimi', name: 'Kimi', base_url: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-128k', has_reasoning: false },\n { id: 'glm', name: 'GLM', base_url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-plus', has_reasoning: false },\n { id: 'mimo', name: 'MiMo', base_url: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-pro', has_reasoning: true },\n { id: 'minimax', name: 'MiniMax', base_url: 'https://api.minimax.chat/v1', model: 'MiniMax-Text-01', has_reasoning: false },\n { id: 'baichuan', name: 'Baichuan', base_url: 'https://api.baichuan-ai.com/v1', model: 'Baichuan4', has_reasoning: false },\n { id: 'yi', name: 'Yi', base_url: 'https://api.lingyiwanwu.com/v1', model: 'yi-lightning', has_reasoning: false },\n { id: 'doubao', name: 'Doubao', base_url: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-1.5-pro-256k', has_reasoning: true },\n { id: 'stepfun', name: 'StepFun', base_url: 'https://api.stepfun.com/v1', model: 'step-2-16k', has_reasoning: false },\n { id: 'siliconflow', name: 'SiliconFlow', base_url: 'https://api.siliconflow.cn/v1', model: 'deepseek-ai/DeepSeek-V3', has_reasoning: true },\n { id: 'anthropic', name: 'Anthropic', base_url: 'https://api.anthropic.com', model: 'claude-sonnet-4-20250514', has_reasoning: true, api_format: 'anthropic' },\n]\n\nexport default function ProviderForm({ provider, onBack }) {\n const { t } = useTranslation()\n const isEdit = !!provider?.id\n\n const [form, setForm] = useState({\n name: '', api_format: 'openai', base_url: '', api_key: '', model: '', has_reasoning: false, note: '', website: '',\n })\n const [testing, setTesting] = useState(false)\n const [testResult, setTestResult] = useState(null)\n const [showKey, setShowKey] = useState(false)\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchedModels, setFetchedModels] = useState([])\n const [showModelDropdown, setShowModelDropdown] = useState(false)\n const [toast, setToast] = useState(null)\n const [showPresets, setShowPresets] = useState(!isEdit)\n const [configText, setConfigText] = useState('')\n const [configError, setConfigError] = useState(null)\n\n useEffect(() => {\n if (provider?.id) {\n const models = provider.models || []\n setForm({\n name: provider.name || '', api_format: provider.api_format || 'openai', base_url: provider.base_url || '',\n api_key: provider.api_key || '', model: models[0] || '', has_reasoning: provider.has_reasoning || false,\n note: provider.note || '', website: provider.website || '',\n })\n }\n }, [provider])\n\n useEffect(() => {\n const preview = {\n name: form.name || 'provider', api_format: form.api_format, base_url: form.base_url,\n api_key: form.api_key ? 'sk-***' : '', models: form.model ? [form.model] : [], has_reasoning: form.has_reasoning,\n }\n setConfigText(JSON.stringify(preview, null, 2))\n setConfigError(null)\n }, [form.name, form.api_format, form.base_url, form.api_key, form.model, form.has_reasoning])\n\n const showToast = (msg, type = 'success') => {\n setToast({ msg, type })\n setTimeout(() => setToast(null), 2500)\n }\n\n const update = (key, val) => setForm(prev => ({ ...prev, [key]: val }))\n\n const applyPreset = (preset) => {\n setForm(prev => ({ ...prev, name: preset.name, base_url: preset.base_url, model: preset.model, has_reasoning: preset.has_reasoning, api_format: preset.api_format || 'openai' }))\n setShowPresets(false)\n }\n\n const handleTest = async () => {\n setTesting(true)\n setTestResult(null)\n try {\n const res = await api.testConnection({ base_url: form.base_url, api_key: form.api_key, api_format: form.api_format, model: form.model || '' })\n setTestResult(res)\n } catch (e) {\n setTestResult({ status: 'error', message: e.message })\n }\n setTesting(false)\n }\n\n const handleFetchModels = async () => {\n setFetchingModels(true)\n try {\n const res = await api.fetchModels({ base_url: form.base_url, api_key: form.api_key, api_format: form.api_format })\n if (res.status === 'ok' && res.models?.length) {\n setFetchedModels(res.models)\n setShowModelDropdown(true)\n showToast(t('providerForm.toastModelsFetched', { count: res.models.length }))\n } else {\n showToast(res.message || t('providerForm.toastNoModels'), 'error')\n }\n } catch (e) {\n showToast(t('providerForm.toastFetchErr') + e.message, 'error')\n }\n setFetchingModels(false)\n }\n\n const handleSave = async () => {\n if (!form.name.trim()) { showToast(t('providerForm.errNoName'), 'error'); return }\n if (!form.model.trim()) { showToast(t('providerForm.errNoModel'), 'error'); return }\n\n let configData\n try { configData = JSON.parse(configText) } catch { showToast(t('providerForm.errBadJson'), 'error'); return }\n\n const data = {\n name: configData.name || form.name.trim(), api_format: form.api_format, base_url: form.base_url.trim(),\n api_key: form.api_key.trim(), models: Array.isArray(configData.models) ? configData.models : [form.model],\n has_reasoning: configData.has_reasoning !== undefined ? configData.has_reasoning : form.has_reasoning,\n note: form.note.trim(), website: form.website.trim(),\n }\n\n try {\n if (isEdit) { await api.updateProvider(provider.id, data); showToast(t('providerForm.toastUpdated')) }\n else { await api.createProvider(data); showToast(t('providerForm.toastAdded')) }\n onBack()\n } catch (e) {\n showToast(t('providerForm.toastSaveErr') + e.message, 'error')\n }\n }\n\n const handleConfigChange = (e) => {\n const val = e.target.value\n setConfigText(val)\n try {\n const parsed = JSON.parse(val)\n setConfigError(null)\n if (parsed.name !== undefined) setForm(prev => ({ ...prev, name: parsed.name }))\n if (parsed.models && Array.isArray(parsed.models) && parsed.models.length > 0) setForm(prev => ({ ...prev, model: parsed.models[0] }))\n if (parsed.has_reasoning !== undefined) setForm(prev => ({ ...prev, has_reasoning: parsed.has_reasoning }))\n } catch { setConfigError(t('providerForm.errInvalidJson')) }\n }\n\n return (\n <div className=\"min-h-screen bg-canvas\">\n {toast && <div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg animate-fade-in ${toast.type === 'error' ? 'bg-error text-on-primary' : 'bg-surface-dark text-on-dark'}`}>{toast.msg}</div>}\n\n <header className=\"border-b border-hairline bg-canvas/80 backdrop-blur-md sticky top-0 z-40\">\n <div className=\"max-w-2xl mx-auto px-6 h-14 flex items-center gap-4\">\n <button onClick={onBack} className=\"w-8 h-8 flex items-center justify-center rounded-lg text-muted hover:text-ink hover:bg-surface transition-colors cursor-pointer\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M19 12H5m7-7l-7 7 7 7\"/></svg>\n </button>\n <h1 className=\"text-base font-semibold text-ink\">{isEdit ? t('providerForm.editTitle') : t('providerForm.addTitle')}</h1>\n <div className=\"flex-1\" />\n <LanguageSwitcher />\n </div>\n </header>\n\n <main className=\"max-w-2xl mx-auto px-6 py-8\">\n {showPresets && !isEdit && (\n <div className=\"mb-8 animate-fade-in\">\n <h2 className=\"text-sm font-medium text-muted mb-3\">{t('providerForm.presetHint')}</h2>\n <div className=\"grid grid-cols-2 sm:grid-cols-3 gap-2\">\n {PRESETS.map(p => (\n <button key={p.id} onClick={() => applyPreset(p)} className=\"flex items-center gap-2 px-3 py-2.5 bg-surface rounded-xl text-left hover:bg-surface-hover transition-colors cursor-pointer\">\n <div className=\"w-7 h-7 rounded-lg bg-coral-bg text-coral flex items-center justify-center text-[10px] font-bold shrink-0\">{p.name.charAt(0)}</div>\n <span className=\"text-xs font-medium text-ink truncate\">{p.name}</span>\n </button>\n ))}\n <button onClick={() => setShowPresets(false)} className=\"flex items-center gap-2 px-3 py-2.5 border border-dashed border-hairline rounded-xl text-left hover:bg-surface transition-colors cursor-pointer\">\n <div className=\"w-7 h-7 rounded-lg bg-surface flex items-center justify-center text-muted\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n </div>\n <span className=\"text-xs font-medium text-muted\">{t('providerForm.custom')}</span>\n </button>\n </div>\n </div>\n )}\n\n <div className=\"space-y-6\">\n <div className=\"flex items-start gap-4\">\n <div className=\"w-16 h-16 rounded-2xl bg-surface flex items-center justify-center text-xl font-bold text-muted shrink-0 mt-1\">{form.name ? form.name.charAt(0).toUpperCase() : '?'}</div>\n <div className=\"flex-1 grid grid-cols-1 sm:grid-cols-2 gap-4\">\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.name')}</label>\n <input type=\"text\" value={form.name} onChange={e => update('name', e.target.value)} placeholder={t('providerForm.namePlaceholder')} className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n </div>\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.note')}</label>\n <input type=\"text\" value={form.note} onChange={e => update('note', e.target.value)} placeholder={t('providerForm.notePlaceholder')} className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n </div>\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.website')}</label>\n <input type=\"url\" value={form.website} onChange={e => update('website', e.target.value)} placeholder=\"https://example.com\" className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.apiFormat')}</label>\n <div className=\"flex gap-2\">\n {['openai', 'anthropic'].map(fmt => (\n <button key={fmt} onClick={() => update('api_format', fmt)} className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer ${form.api_format === fmt ? 'bg-coral text-on-primary' : 'bg-surface text-muted hover:bg-surface-hover'}`}>\n {fmt === 'openai' ? t('providerForm.openaiCompat') : 'Anthropic'}\n </button>\n ))}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.apiKey')}</label>\n <div className=\"relative\">\n <input type={showKey ? 'text' : 'password'} value={form.api_key} onChange={e => update('api_key', e.target.value)} placeholder=\"sk-...\" className=\"w-full px-3 py-2 pr-10 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors font-mono\" />\n <button type=\"button\" onClick={() => setShowKey(!showKey)} className=\"absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-muted hover:text-ink transition-colors cursor-pointer\">\n {showKey ? <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg> : <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24\"/><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"/></svg>}\n </button>\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.baseUrl')}</label>\n <input type=\"url\" value={form.base_url} onChange={e => update('base_url', e.target.value)} placeholder=\"https://api.example.com/v1\" className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n <p className=\"text-xs text-muted mt-1\">{t('providerForm.baseUrlHelp')}</p>\n </div>\n\n <div>\n <div className=\"flex items-center justify-between mb-1.5\">\n <label className=\"text-sm font-medium text-ink\">{t('providerForm.model')}</label>\n <button onClick={handleFetchModels} disabled={fetchingModels || !form.base_url || !form.api_key} className=\"flex items-center gap-1 text-xs text-coral hover:text-coral-active transition-colors disabled:opacity-40 cursor-pointer\">\n {fetchingModels ? <svg className=\"animate-spin\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83\"/></svg> : <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>}\n {t('providerForm.fetchModels')}\n </button>\n </div>\n <div className=\"relative\">\n <input type=\"text\" value={form.model} onChange={e => { update('model', e.target.value); setShowModelDropdown(false) }} onFocus={() => { if (fetchedModels.length) setShowModelDropdown(true) }} placeholder=\"deepseek-chat\" className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n {showModelDropdown && fetchedModels.length > 0 && (\n <div className=\"absolute z-10 w-full mt-1 bg-canvas border border-hairline rounded-xl shadow-lg max-h-48 overflow-y-auto\">\n <button onClick={() => setShowModelDropdown(false)} className=\"absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-muted hover:text-ink cursor-pointer\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>\n </button>\n {fetchedModels.map(m => (\n <button key={m} onClick={() => { update('model', m); setShowModelDropdown(false) }} className={`w-full text-left px-3 py-2 text-sm hover:bg-surface transition-colors cursor-pointer ${form.model === m ? 'bg-coral-bg text-coral font-medium' : 'text-ink'}`}>{m}</button>\n ))}\n </div>\n )}\n </div>\n <p className=\"text-xs text-muted mt-1\">{t('providerForm.modelHelp')}</p>\n </div>\n\n <div className=\"flex items-center gap-3\">\n <button onClick={() => update('has_reasoning', !form.has_reasoning)} className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${form.has_reasoning ? 'bg-coral' : 'bg-hairline'}`}>\n <div className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm transition-all ${form.has_reasoning ? 'left-5' : 'left-1'}`} />\n </button>\n <span className=\"text-sm font-medium text-ink\">{t('providerForm.reasoning')}</span>\n </div>\n\n <div className=\"bg-surface rounded-xl p-4\">\n <div className=\"flex items-center justify-between mb-3\">\n <span className=\"text-sm font-medium text-ink\">{t('providerForm.testSection')}</span>\n <button onClick={handleTest} disabled={testing || !form.base_url || !form.api_key} className=\"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-coral text-on-primary rounded-lg hover:bg-coral-active transition-colors disabled:opacity-40 cursor-pointer\">\n {testing ? <>{t('providerForm.testing')}</> : <>{t('providerForm.testBtn')}</>}\n </button>\n </div>\n {testResult && (\n <div className={`px-3 py-2 rounded-lg text-xs ${testResult.status === 'ok' ? 'bg-success-bg text-success' : 'bg-error-bg text-error'}`}>\n <span className=\"font-medium\">{testResult.status === 'ok' ? t('providerForm.testOk') : t('providerForm.testFail')}</span>\n {testResult.latency_ms && <span className=\"ml-2 opacity-70\">{testResult.latency_ms}ms</span>}\n {testResult.message && <span className=\"ml-2\">{testResult.message}</span>}\n </div>\n )}\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.configPreview')}</label>\n <textarea value={configText} onChange={handleConfigChange} spellCheck={false} className=\"w-full bg-surface-dark rounded-xl p-4 text-xs text-on-dark/80 font-mono leading-relaxed overflow-x-auto border border-hairline focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors resize-y min-h-[180px]\" rows={12} />\n {configError && <p className=\"text-xs text-error mt-1\">{configError}</p>}\n </div>\n\n <div className=\"flex justify-end pt-4 pb-8\">\n <button onClick={handleSave} className=\"flex items-center gap-2 px-6 py-2.5 bg-coral text-on-primary rounded-xl text-sm font-medium hover:bg-coral-active transition-colors cursor-pointer\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>\n {t('providerForm.save')}\n </button>\n </div>\n </div>\n </main>\n </div>\n )\n}\n"}, "68ec8644cd46f2d31bb5e6ea2422216af67142ee": {"t": 1785764163.7334774, "n": 19377, "h": "dde539c81afe7228", "c": "import { useState, useEffect } from 'react'\nimport { api } from '../lib/api'\nimport LanguageSwitcher from '../components/LanguageSwitcher'\nimport { useTranslation } from '../i18n'\n\nconst PRESETS = [\n { id: 'deepseek', name: 'DeepSeek', base_url: 'https://api.deepseek.com', model: 'deepseek-chat', has_reasoning: true },\n { id: 'qwen', name: 'Qwen', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-max', has_reasoning: true },\n { id: 'kimi', name: 'Kimi', base_url: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-128k', has_reasoning: false },\n { id: 'glm', name: 'GLM', base_url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-plus', has_reasoning: false },\n { id: 'mimo', name: 'MiMo', base_url: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-pro', has_reasoning: true },\n { id: 'minimax', name: 'MiniMax', base_url: 'https://api.minimax.chat/v1', model: 'MiniMax-Text-01', has_reasoning: false },\n { id: 'baichuan', name: 'Baichuan', base_url: 'https://api.baichuan-ai.com/v1', model: 'Baichuan4', has_reasoning: false },\n { id: 'yi', name: 'Yi', base_url: 'https://api.lingyiwanwu.com/v1', model: 'yi-lightning', has_reasoning: false },\n { id: 'doubao', name: 'Doubao', base_url: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-1.5-pro-256k', has_reasoning: true },\n { id: 'stepfun', name: 'StepFun', base_url: 'https://api.stepfun.com/v1', model: 'step-2-16k', has_reasoning: false },\n { id: 'siliconflow', name: 'SiliconFlow', base_url: 'https://api.siliconflow.cn/v1', model: 'deepseek-ai/DeepSeek-V3', has_reasoning: true },\n { id: 'anthropic', name: 'Anthropic', base_url: 'https://api.anthropic.com', model: 'claude-sonnet-4-20250514', has_reasoning: true, api_format: 'anthropic' },\n]\n\nexport default function ProviderForm({ provider, onBack }) {\n const { t } = useTranslation()\n const isEdit = !!provider?.id\n\n const [form, setForm] = useState({\n name: '', api_format: 'openai', base_url: '', api_key: '', model: '', has_reasoning: false, note: '', website: '',\n })\n const [testing, setTesting] = useState(false)\n const [testResult, setTestResult] = useState(null)\n const [showKey, setShowKey] = useState(false)\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchedModels, setFetchedModels] = useState([])\n const [showModelDropdown, setShowModelDropdown] = useState(false)\n const [toast, setToast] = useState(null)\n const [showPresets, setShowPresets] = useState(!isEdit)\n const [configText, setConfigText] = useState('')\n const [configError, setConfigError] = useState(null)\n\n useEffect(() => {\n if (provider?.id) {\n const models = provider.models || []\n setForm({\n name: provider.name || '', api_format: provider.api_format || 'openai', base_url: provider.base_url || '',\n api_key: provider.api_key || '', model: models[0] || '', has_reasoning: provider.has_reasoning || false,\n note: provider.note || '', website: provider.website || '',\n })\n }\n }, [provider])\n\n useEffect(() => {\n const preview = {\n name: form.name || 'provider', api_format: form.api_format, base_url: form.base_url,\n api_key: form.api_key ? 'sk-***' : '', models: form.model ? [form.model] : [], has_reasoning: form.has_reasoning,\n }\n setConfigText(JSON.stringify(preview, null, 2))\n setConfigError(null)\n }, [form.name, form.api_format, form.base_url, form.api_key, form.model, form.has_reasoning])\n\n const showToast = (msg, type = 'success') => {\n setToast({ msg, type })\n setTimeout(() => setToast(null), 2500)\n }\n\n const update = (key, val) => setForm(prev => ({ ...prev, [key]: val }))\n\n const applyPreset = (preset) => {\n setForm(prev => ({ ...prev, name: preset.name, base_url: preset.base_url, model: preset.model, has_reasoning: preset.has_reasoning, api_format: preset.api_format || 'openai' }))\n setShowPresets(false)\n }\n\n const handleTest = async () => {\n setTesting(true)\n setTestResult(null)\n try {\n const res = await api.testConnection({ base_url: form.base_url, api_key: form.api_key, api_format: form.api_format, model: form.model || '' })\n setTestResult(res)\n } catch (e) {\n setTestResult({ status: 'error', message: e.message })\n }\n setTesting(false)\n }\n\n const handleFetchModels = async () => {\n setFetchingModels(true)\n try {\n const res = await api.fetchModels({ base_url: form.base_url, api_key: form.api_key, api_format: form.api_format })\n if (res.status === 'ok' && res.models?.length) {\n setFetchedModels(res.models)\n setShowModelDropdown(true)\n showToast(t('providerForm.toastModelsFetched', { count: res.models.length }))\n } else {\n showToast(res.message || t('providerForm.toastNoModels'), 'error')\n }\n } catch (e) {\n showToast(t('providerForm.toastFetchErr') + e.message, 'error')\n }\n setFetchingModels(false)\n }\n\n const handleSave = async () => {\n if (!form.name.trim()) { showToast(t('providerForm.errNoName'), 'error'); return }\n if (!form.model.trim()) { showToast(t('providerForm.errNoModel'), 'error'); return }\n\n let configData\n try { configData = JSON.parse(configText) } catch { showToast(t('providerForm.errBadJson'), 'error'); return }\n\n const data = {\n name: configData.name || form.name.trim(), api_format: form.api_format, base_url: form.base_url.trim(),\n api_key: form.api_key.trim(), models: Array.isArray(configData.models) ? configData.models : [form.model],\n has_reasoning: configData.has_reasoning !== undefined ? configData.has_reasoning : form.has_reasoning,\n note: form.note.trim(), website: form.website.trim(),\n }\n\n try {\n if (isEdit) { await api.updateProvider(provider.id, data); showToast(t('providerForm.toastUpdated')) }\n else { await api.createProvider(data); showToast(t('providerForm.toastAdded')) }\n onBack()\n } catch (e) {\n showToast(t('providerForm.toastSaveErr') + e.message, 'error')\n }\n }\n\n const handleConfigChange = (e) => {\n const val = e.target.value\n setConfigText(val)\n try {\n const parsed = JSON.parse(val)\n setConfigError(null)\n if (parsed.name !== undefined) setForm(prev => ({ ...prev, name: parsed.name }))\n if (parsed.models && Array.isArray(parsed.models) && parsed.models.length > 0) setForm(prev => ({ ...prev, model: parsed.models[0] }))\n if (parsed.has_reasoning !== undefined) setForm(prev => ({ ...prev, has_reasoning: parsed.has_reasoning }))\n } catch { setConfigError(t('providerForm.errInvalidJson')) }\n }\n\n return (\n <div className=\"min-h-screen bg-canvas\">\n {toast && <div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg animate-fade-in ${toast.type === 'error' ? 'bg-error text-on-primary' : 'bg-surface-dark text-on-dark'}`}>{toast.msg}</div>}\n\n <header className=\"border-b border-hairline bg-canvas/80 backdrop-blur-md sticky top-0 z-40\">\n <div className=\"max-w-2xl mx-auto px-6 h-14 flex items-center gap-4\">\n <button onClick={onBack} className=\"w-8 h-8 flex items-center justify-center rounded-lg text-muted hover:text-ink hover:bg-surface transition-colors cursor-pointer\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M19 12H5m7-7l-7 7 7 7\"/></svg>\n </button>\n <h1 className=\"text-base font-semibold text-ink\">{isEdit ? t('providerForm.editTitle') : t('providerForm.addTitle')}</h1>\n <div className=\"flex-1\" />\n <LanguageSwitcher />\n </div>\n </header>\n\n <main className=\"max-w-2xl mx-auto px-6 py-8\">\n {showPresets && !isEdit && (\n <div className=\"mb-8 animate-fade-in\">\n <h2 className=\"text-sm font-medium text-muted mb-3\">{t('providerForm.presetHint')}</h2>\n <div className=\"grid grid-cols-2 sm:grid-cols-3 gap-2\">\n {PRESETS.map(p => (\n <button key={p.id} onClick={() => applyPreset(p)} className=\"flex items-center gap-2 px-3 py-2.5 bg-surface rounded-xl text-left hover:bg-surface-hover transition-colors cursor-pointer\">\n <div className=\"w-7 h-7 rounded-lg bg-coral-bg text-coral flex items-center justify-center text-[10px] font-bold shrink-0\">{p.name.charAt(0)}</div>\n <span className=\"text-xs font-medium text-ink truncate\">{p.name}</span>\n </button>\n ))}\n <button onClick={() => setShowPresets(false)} className=\"flex items-center gap-2 px-3 py-2.5 border border-dashed border-hairline rounded-xl text-left hover:bg-surface transition-colors cursor-pointer\">\n <div className=\"w-7 h-7 rounded-lg bg-surface flex items-center justify-center text-muted\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n </div>\n <span className=\"text-xs font-medium text-muted\">{t('providerForm.custom')}</span>\n </button>\n </div>\n </div>\n )}\n\n <div className=\"space-y-6\">\n <div className=\"flex items-start gap-4\">\n <div className=\"w-16 h-16 rounded-2xl bg-surface flex items-center justify-center text-xl font-bold text-muted shrink-0 mt-1\">{form.name ? form.name.charAt(0).toUpperCase() : '?'}</div>\n <div className=\"flex-1 grid grid-cols-1 sm:grid-cols-2 gap-4\">\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.name')}</label>\n <input type=\"text\" value={form.name} onChange={e => update('name', e.target.value)} placeholder={t('providerForm.namePlaceholder')} className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n </div>\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.note')}</label>\n <input type=\"text\" value={form.note} onChange={e => update('note', e.target.value)} placeholder={t('providerForm.notePlaceholder')} className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n </div>\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.website')}</label>\n <input type=\"url\" value={form.website} onChange={e => update('website', e.target.value)} placeholder=\"https://example.com\" className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.apiFormat')}</label>\n <div className=\"flex gap-2\">\n {['openai', 'anthropic'].map(fmt => (\n <button key={fmt} onClick={() => update('api_format', fmt)} className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer ${form.api_format === fmt ? 'bg-coral text-on-primary' : 'bg-surface text-muted hover:bg-surface-hover'}`}>\n {fmt === 'openai' ? t('providerForm.openaiCompat') : 'Anthropic'}\n </button>\n ))}\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.apiKey')}</label>\n <div className=\"relative\">\n <input type={showKey ? 'text' : 'password'} value={form.api_key} onChange={e => update('api_key', e.target.value)} placeholder=\"sk-...\" className=\"w-full px-3 py-2 pr-10 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors font-mono\" />\n <button type=\"button\" onClick={() => setShowKey(!showKey)} className=\"absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-muted hover:text-ink transition-colors cursor-pointer\">\n {showKey ? <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg> : <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24\"/><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"/></svg>}\n </button>\n </div>\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.baseUrl')}</label>\n <input type=\"url\" value={form.base_url} onChange={e => update('base_url', e.target.value)} placeholder=\"https://api.example.com/v1\" className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n <p className=\"text-xs text-muted mt-1\">{t('providerForm.baseUrlHelp')}</p>\n </div>\n\n <div>\n <div className=\"flex items-center justify-between mb-1.5\">\n <label className=\"text-sm font-medium text-ink\">{t('providerForm.model')}</label>\n <button onClick={handleFetchModels} disabled={fetchingModels || !form.base_url || !form.api_key} className=\"flex items-center gap-1 text-xs text-coral hover:text-coral-active transition-colors disabled:opacity-40 cursor-pointer\">\n {fetchingModels ? <svg className=\"animate-spin\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83\"/></svg> : <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>}\n {t('providerForm.fetchModels')}\n </button>\n </div>\n <div className=\"relative\">\n <input type=\"text\" value={form.model} onChange={e => { update('model', e.target.value); setShowModelDropdown(false) }} onFocus={() => { if (fetchedModels.length) setShowModelDropdown(true) }} placeholder=\"deepseek-chat\" className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\" />\n {showModelDropdown && fetchedModels.length > 0 && (\n <div className=\"absolute z-10 w-full mt-1 bg-canvas border border-hairline rounded-xl shadow-lg max-h-48 overflow-y-auto\">\n <button onClick={() => setShowModelDropdown(false)} className=\"absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-muted hover:text-ink cursor-pointer\">\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>\n </button>\n {fetchedModels.map(m => (\n <button key={m} onClick={() => { update('model', m); setShowModelDropdown(false) }} className={`w-full text-left px-3 py-2 text-sm hover:bg-surface transition-colors cursor-pointer ${form.model === m ? 'bg-coral-bg text-coral font-medium' : 'text-ink'}`}>{m}</button>\n ))}\n </div>\n )}\n </div>\n <p className=\"text-xs text-muted mt-1\">{t('providerForm.modelHelp')}</p>\n </div>\n\n <div className=\"flex items-center gap-3\">\n <button onClick={() => update('has_reasoning', !form.has_reasoning)} className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${form.has_reasoning ? 'bg-coral' : 'bg-hairline'}`}>\n <div className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm transition-all ${form.has_reasoning ? 'left-5' : 'left-1'}`} />\n </button>\n <span className=\"text-sm font-medium text-ink\">{t('providerForm.reasoning')}</span>\n </div>\n\n <div className=\"bg-surface rounded-xl p-4\">\n <div className=\"flex items-center justify-between mb-3\">\n <span className=\"text-sm font-medium text-ink\">{t('providerForm.testSection')}</span>\n <button onClick={handleTest} disabled={testing || !form.base_url || !form.api_key} className=\"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-coral text-on-primary rounded-lg hover:bg-coral-active transition-colors disabled:opacity-40 cursor-pointer\">\n {testing ? <>{t('providerForm.testing')}</> : <>{t('providerForm.testBtn')}</>}\n </button>\n </div>\n {testResult && (\n <div className={`px-3 py-2 rounded-lg text-xs ${testResult.status === 'ok' ? 'bg-success-bg text-success' : 'bg-error-bg text-error'}`}>\n <span className=\"font-medium\">{testResult.status === 'ok' ? t('providerForm.testOk') : t('providerForm.testFail')}</span>\n {testResult.latency_ms && <span className=\"ml-2 opacity-70\">{testResult.latency_ms}ms</span>}\n {testResult.message && <span className=\"ml-2\">{testResult.message}</span>}\n </div>\n )}\n </div>\n\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">{t('providerForm.configPreview')}</label>\n <textarea value={configText} onChange={handleConfigChange} spellCheck={false} className=\"w-full bg-surface-dark rounded-xl p-4 text-xs text-on-dark/80 font-mono leading-relaxed overflow-x-auto border border-hairline focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors resize-y min-h-[180px]\" rows={12} />\n {configError && <p className=\"text-xs text-error mt-1\">{configError}</p>}\n </div>\n\n <div className=\"flex justify-end pt-4 pb-8\">\n <button onClick={handleSave} className=\"flex items-center gap-2 px-6 py-2.5 bg-coral text-on-primary rounded-xl text-sm font-medium hover:bg-coral-active transition-colors cursor-pointer\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>\n {t('providerForm.save')}\n </button>\n </div>\n </div>\n </main>\n </div>\n )\n}\n"}, "d2030da84f65ed15a1e87915e48ab3ecb9e36a85": {"t": 1785764165.2528746, "n": 22237, "h": "b1581063c9a94678", "c": "import { useState, useEffect } from 'react'\nimport { api } from '../lib/api'\n\nconst PRESETS = [\n { id: 'deepseek', name: 'DeepSeek', base_url: 'https://api.deepseek.com', model: 'deepseek-chat', has_reasoning: true },\n { id: 'qwen', name: 'Qwen (通义千问)', base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-max', has_reasoning: true },\n { id: 'kimi', name: 'Kimi (月之暗面)', base_url: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-128k', has_reasoning: false },\n { id: 'glm', name: 'GLM (智谱)', base_url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-plus', has_reasoning: false },\n { id: 'mimo', name: 'MiMo (小米)', base_url: 'https://api.xiaomimimo.com/v1', model: 'mimo-v2.5-pro', has_reasoning: true },\n { id: 'minimax', name: 'MiniMax', base_url: 'https://api.minimax.chat/v1', model: 'MiniMax-Text-01', has_reasoning: false },\n { id: 'baichuan', name: 'Baichuan (百川)', base_url: 'https://api.baichuan-ai.com/v1', model: 'Baichuan4', has_reasoning: false },\n { id: 'yi', name: 'Yi (零一万物)', base_url: 'https://api.lingyiwanwu.com/v1', model: 'yi-lightning', has_reasoning: false },\n { id: 'doubao', name: 'Doubao (豆包)', base_url: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-1.5-pro-256k', has_reasoning: true },\n { id: 'stepfun', name: 'StepFun (阶跃星辰)', base_url: 'https://api.stepfun.com/v1', model: 'step-2-16k', has_reasoning: false },\n { id: 'siliconflow', name: 'SiliconFlow (硅基流动)', base_url: 'https://api.siliconflow.cn/v1', model: 'deepseek-ai/DeepSeek-V3', has_reasoning: true },\n { id: 'anthropic', name: 'Anthropic (Claude)', base_url: 'https://api.anthropic.com', model: 'claude-sonnet-4-20250514', has_reasoning: true, api_format: 'anthropic' },\n]\n\nexport default function ProviderForm({ provider, onBack }) {\n const isEdit = !!provider?.id\n\n const [form, setForm] = useState({\n name: '',\n api_format: 'openai',\n base_url: '',\n api_key: '',\n model: '',\n has_reasoning: false,\n note: '',\n website: '',\n })\n\n const [testing, setTesting] = useState(false)\n const [testResult, setTestResult] = useState(null)\n const [showKey, setShowKey] = useState(false)\n const [fetchingModels, setFetchingModels] = useState(false)\n const [fetchedModels, setFetchedModels] = useState([])\n const [showModelDropdown, setShowModelDropdown] = useState(false)\n const [toast, setToast] = useState(null)\n const [showPresets, setShowPresets] = useState(!isEdit)\n\n // Config preview - editable\n const [configText, setConfigText] = useState('')\n const [configError, setConfigError] = useState(null)\n\n useEffect(() => {\n if (provider?.id) {\n const models = provider.models || []\n setForm({\n name: provider.name || '',\n api_format: provider.api_format || 'openai',\n base_url: provider.base_url || '',\n api_key: provider.api_key || '',\n model: models[0] || '',\n has_reasoning: provider.has_reasoning || false,\n note: provider.note || '',\n website: provider.website || '',\n })\n }\n }, [provider])\n\n // Keep config preview in sync\n useEffect(() => {\n const preview = {\n name: form.name || 'provider',\n api_format: form.api_format,\n base_url: form.base_url,\n api_key: form.api_key ? 'sk-***' : '',\n models: form.model ? [form.model] : [],\n has_reasoning: form.has_reasoning,\n }\n setConfigText(JSON.stringify(preview, null, 2))\n setConfigError(null)\n }, [form.name, form.api_format, form.base_url, form.api_key, form.model, form.has_reasoning])\n\n const showToast = (msg, type = 'success') => {\n setToast({ msg, type })\n setTimeout(() => setToast(null), 2500)\n }\n\n const update = (key, val) => setForm(prev => ({ ...prev, [key]: val }))\n\n const applyPreset = (preset) => {\n setForm(prev => ({\n ...prev,\n name: preset.name,\n base_url: preset.base_url,\n model: preset.model,\n has_reasoning: preset.has_reasoning,\n api_format: preset.api_format || 'openai',\n }))\n setShowPresets(false)\n }\n\n const handleTest = async () => {\n setTesting(true)\n setTestResult(null)\n try {\n const res = await api.testConnection({\n base_url: form.base_url,\n api_key: form.api_key,\n api_format: form.api_format,\n model: form.model || '',\n })\n setTestResult(res)\n } catch (e) {\n setTestResult({ status: 'error', message: e.message })\n }\n setTesting(false)\n }\n\n const handleFetchModels = async () => {\n setFetchingModels(true)\n try {\n const res = await api.fetchModels({\n base_url: form.base_url,\n api_key: form.api_key,\n api_format: form.api_format,\n })\n if (res.status === 'ok' && res.models?.length) {\n setFetchedModels(res.models)\n setShowModelDropdown(true)\n showToast(`获取到 ${res.models.length} 个模型`)\n } else {\n showToast(res.message || '未获取到模型', 'error')\n }\n } catch (e) {\n showToast('获取失败: ' + e.message, 'error')\n }\n setFetchingModels(false)\n }\n\n const handleSave = async () => {\n if (!form.name.trim()) {\n showToast('请输入供应商名称', 'error')\n return\n }\n if (!form.model.trim()) {\n showToast('请输入模型名称', 'error')\n return\n }\n\n // Parse config from the editable preview\n let configData\n try {\n configData = JSON.parse(configText)\n } catch {\n showToast('配置 JSON 格式错误,请检查', 'error')\n return\n }\n\n const data = {\n name: configData.name || form.name.trim(),\n api_format: form.api_format,\n base_url: form.base_url.trim(),\n api_key: form.api_key.trim(),\n models: Array.isArray(configData.models) ? configData.models : [form.model],\n has_reasoning: configData.has_reasoning !== undefined ? configData.has_reasoning : form.has_reasoning,\n note: form.note.trim(),\n website: form.website.trim(),\n }\n\n try {\n if (isEdit) {\n await api.updateProvider(provider.id, data)\n showToast('已更新')\n } else {\n await api.createProvider(data)\n showToast('已添加')\n }\n onBack()\n } catch (e) {\n showToast('保存失败: ' + e.message, 'error')\n }\n }\n\n const handleConfigChange = (e) => {\n const val = e.target.value\n setConfigText(val)\n try {\n const parsed = JSON.parse(val)\n setConfigError(null)\n // Sync form fields from edited config\n if (parsed.name !== undefined) setForm(prev => ({ ...prev, name: parsed.name }))\n if (parsed.models && Array.isArray(parsed.models) && parsed.models.length > 0) {\n setForm(prev => ({ ...prev, model: parsed.models[0] }))\n }\n if (parsed.has_reasoning !== undefined) setForm(prev => ({ ...prev, has_reasoning: parsed.has_reasoning }))\n } catch {\n setConfigError('JSON 格式无效')\n }\n }\n\n return (\n <div className=\"min-h-screen bg-canvas\">\n {toast && (\n <div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg animate-fade-in ${\n toast.type === 'error' ? 'bg-error text-on-primary' : 'bg-surface-dark text-on-dark'\n }`}>\n {toast.msg}\n </div>\n )}\n\n {/* Header */}\n <header className=\"border-b border-hairline bg-canvas/80 backdrop-blur-md sticky top-0 z-40\">\n <div className=\"max-w-2xl mx-auto px-6 h-14 flex items-center gap-4\">\n <button\n onClick={onBack}\n className=\"w-8 h-8 flex items-center justify-center rounded-lg text-muted hover:text-ink hover:bg-surface transition-colors cursor-pointer\"\n >\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M19 12H5m7-7l-7 7 7 7\"/></svg>\n </button>\n <h1 className=\"text-base font-semibold text-ink\">{isEdit ? '编辑供应商' : '添加供应商'}</h1>\n </div>\n </header>\n\n <main className=\"max-w-2xl mx-auto px-6 py-8\">\n {/* Preset Selector (only for new) */}\n {showPresets && !isEdit && (\n <div className=\"mb-8 animate-fade-in\">\n <h2 className=\"text-sm font-medium text-muted mb-3\">选择预设模板快速添加</h2>\n <div className=\"grid grid-cols-2 sm:grid-cols-3 gap-2\">\n {PRESETS.map(p => (\n <button\n key={p.id}\n onClick={() => applyPreset(p)}\n className=\"flex items-center gap-2 px-3 py-2.5 bg-surface rounded-xl text-left hover:bg-surface-hover transition-colors cursor-pointer\"\n >\n <div className=\"w-7 h-7 rounded-lg bg-coral-bg text-coral flex items-center justify-center text-[10px] font-bold shrink-0\">\n {p.name.charAt(0)}\n </div>\n <span className=\"text-xs font-medium text-ink truncate\">{p.name}</span>\n </button>\n ))}\n <button\n onClick={() => setShowPresets(false)}\n className=\"flex items-center gap-2 px-3 py-2.5 border border-dashed border-hairline rounded-xl text-left hover:bg-surface transition-colors cursor-pointer\"\n >\n <div className=\"w-7 h-7 rounded-lg bg-surface flex items-center justify-center text-muted\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"/><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"/></svg>\n </div>\n <span className=\"text-xs font-medium text-muted\">自定义</span>\n </button>\n </div>\n </div>\n )}\n\n <div className=\"space-y-6\">\n {/* Avatar + Name */}\n <div className=\"flex items-start gap-4\">\n <div className=\"w-16 h-16 rounded-2xl bg-surface flex items-center justify-center text-xl font-bold text-muted shrink-0 mt-1\">\n {form.name ? form.name.charAt(0).toUpperCase() : '?'}\n </div>\n <div className=\"flex-1 grid grid-cols-1 sm:grid-cols-2 gap-4\">\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">供应商名称 *</label>\n <input\n type=\"text\"\n value={form.name}\n onChange={e => update('name', e.target.value)}\n placeholder=\"例如: DeepSeek\"\n className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\"\n />\n </div>\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">备注</label>\n <input\n type=\"text\"\n value={form.note}\n onChange={e => update('note', e.target.value)}\n placeholder=\"例如: 公司专用账号\"\n className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\"\n />\n </div>\n </div>\n </div>\n\n {/* Website */}\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">官网链接</label>\n <input\n type=\"url\"\n value={form.website}\n onChange={e => update('website', e.target.value)}\n placeholder=\"https://example.com\"\n className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\"\n />\n </div>\n\n {/* API Format */}\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">API 格式</label>\n <div className=\"flex gap-2\">\n {['openai', 'anthropic'].map(fmt => (\n <button\n key={fmt}\n onClick={() => update('api_format', fmt)}\n className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer ${\n form.api_format === fmt\n ? 'bg-coral text-on-primary'\n : 'bg-surface text-muted hover:bg-surface-hover'\n }`}\n >\n {fmt === 'openai' ? 'OpenAI 兼容' : 'Anthropic'}\n </button>\n ))}\n </div>\n </div>\n\n {/* API Key */}\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">API Key</label>\n <div className=\"relative\">\n <input\n type={showKey ? 'text' : 'password'}\n value={form.api_key}\n onChange={e => update('api_key', e.target.value)}\n placeholder=\"sk-...\"\n className=\"w-full px-3 py-2 pr-10 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors font-mono\"\n />\n <button\n type=\"button\"\n onClick={() => setShowKey(!showKey)}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-muted hover:text-ink transition-colors cursor-pointer\"\n >\n {showKey ? (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/></svg>\n ) : (\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24\"/><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"/></svg>\n )}\n </button>\n </div>\n </div>\n\n {/* API Base URL */}\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">API 请求地址</label>\n <input\n type=\"url\"\n value={form.base_url}\n onChange={e => update('base_url', e.target.value)}\n placeholder=\"https://api.example.com/v1\"\n className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\"\n />\n <p className=\"text-xs text-muted mt-1\">请填写完整请求 URL,代理会自动拼接 /chat/completions 路径</p>\n </div>\n\n {/* Model Name - Single input */}\n <div>\n <div className=\"flex items-center justify-between mb-1.5\">\n <label className=\"text-sm font-medium text-ink\">模型名称 *</label>\n <button\n onClick={handleFetchModels}\n disabled={fetchingModels || !form.base_url || !form.api_key}\n className=\"flex items-center gap-1 text-xs text-coral hover:text-coral-active transition-colors disabled:opacity-40 cursor-pointer\"\n >\n {fetchingModels ? (\n <svg className=\"animate-spin\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83\"/></svg>\n ) : (\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4\"/><polyline points=\"7 10 12 15 17 10\"/><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"/></svg>\n )}\n 获取模型列表\n </button>\n </div>\n <div className=\"relative\">\n <input\n type=\"text\"\n value={form.model}\n onChange={e => {\n update('model', e.target.value)\n setShowModelDropdown(false)\n }}\n onFocus={() => { if (fetchedModels.length) setShowModelDropdown(true) }}\n placeholder=\"例如: deepseek-chat\"\n className=\"w-full px-3 py-2 bg-canvas border border-hairline rounded-xl text-sm text-ink placeholder:text-muted/50 focus:outline-none focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors\"\n />\n {showModelDropdown && fetchedModels.length > 0 && (\n <div className=\"absolute z-10 w-full mt-1 bg-canvas border border-hairline rounded-xl shadow-lg max-h-48 overflow-y-auto\">\n <button\n onClick={() => { setShowModelDropdown(false) }}\n className=\"absolute top-2 right-2 w-6 h-6 flex items-center justify-center rounded text-muted hover:text-ink cursor-pointer\"\n >\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>\n </button>\n {fetchedModels.map(m => (\n <button\n key={m}\n onClick={() => {\n update('model', m)\n setShowModelDropdown(false)\n }}\n className={`w-full text-left px-3 py-2 text-sm hover:bg-surface transition-colors cursor-pointer ${\n form.model === m ? 'bg-coral-bg text-coral font-medium' : 'text-ink'\n }`}\n >\n {m}\n </button>\n ))}\n </div>\n )}\n </div>\n <p className=\"text-xs text-muted mt-1\">填写一个模型名称,代理会转发给对应 API</p>\n </div>\n\n {/* Reasoning Toggle */}\n <div className=\"flex items-center gap-3\">\n <button\n onClick={() => update('has_reasoning', !form.has_reasoning)}\n className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${\n form.has_reasoning ? 'bg-coral' : 'bg-hairline'\n }`}\n >\n <div className={`absolute top-1 w-4 h-4 rounded-full bg-white shadow-sm transition-all ${\n form.has_reasoning ? 'left-5' : 'left-1'\n }`} />\n </button>\n <div>\n <span className=\"text-sm font-medium text-ink\">支持推理内容</span>\n <span className=\"text-xs text-muted ml-2\">(reasoning_content / thinking 字段)</span>\n </div>\n </div>\n\n {/* Test Connection */}\n <div className=\"bg-surface rounded-xl p-4\">\n <div className=\"flex items-center justify-between mb-3\">\n <span className=\"text-sm font-medium text-ink\">测试连接</span>\n <button\n onClick={handleTest}\n disabled={testing || !form.base_url || !form.api_key}\n className=\"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-coral text-on-primary rounded-lg hover:bg-coral-active transition-colors disabled:opacity-40 cursor-pointer\"\n >\n {testing ? (\n <>\n <svg className=\"animate-spin\" width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M12 2v4m0 12v4m-7.07-3.93l2.83-2.83m8.48-8.48l2.83-2.83M2 12h4m12 0h4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83\"/></svg>\n 测试中...\n </>\n ) : (\n <>\n <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M13 10V3L4 14h7v7l9-11h-7z\"/></svg>\n 发送测试请求\n </>\n )}\n </button>\n </div>\n {testResult && (\n <div className={`px-3 py-2 rounded-lg text-xs ${\n testResult.status === 'ok'\n ? 'bg-success-bg text-success'\n : 'bg-error-bg text-error'\n }`}>\n <span className=\"font-medium\">\n {testResult.status === 'ok' ? '连接成功' : '连接失败'}\n </span>\n {testResult.latency_ms && <span className=\"ml-2 opacity-70\">{testResult.latency_ms}ms</span>}\n {testResult.message && <span className=\"ml-2\">{testResult.message}</span>}\n </div>\n )}\n </div>\n\n {/* Config Preview - Editable */}\n <div>\n <label className=\"block text-sm font-medium text-ink mb-1.5\">配置预览</label>\n <textarea\n value={configText}\n onChange={handleConfigChange}\n spellCheck={false}\n className=\"w-full bg-surface-dark rounded-xl p-4 text-xs text-on-dark/80 font-mono leading-relaxed overflow-x-auto border border-hairline focus:border-coral/50 focus:ring-1 focus:ring-coral/20 transition-colors resize-y min-h-[180px]\"\n rows={12}\n />\n {configError && (\n <p className=\"text-xs text-error mt-1\">{configError}</p>\n )}\n </div>\n\n {/* Save Button */}\n <div className=\"flex justify-end pt-4 pb-8\">\n <button\n onClick={handleSave}\n className=\"flex items-center gap-2 px-6 py-2.5 bg-coral text-on-primary rounded-xl text-sm font-medium hover:bg-coral-active transition-colors cursor-pointer\"\n >\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\"><path d=\"M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z\"/><polyline points=\"17 21 17 13 7 13 7 21\"/><polyline points=\"7 3 7 8 15 8\"/></svg>\n 保存\n </button>\n </div>\n </div>\n </main>\n </div>\n )\n}\n"}}}} |