681 lines
35 KiB
TypeScript
681 lines
35 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { adminAPI } from '@/api';
|
||
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
|
||
|
||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
{children}
|
||
</svg>
|
||
);
|
||
|
||
const formatNumber = (n: number) => {
|
||
if (n >= 100000000) return (n / 100000000).toFixed(1) + '亿';
|
||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||
return n.toLocaleString();
|
||
};
|
||
|
||
const capabilityColors: Record<string, string> = {
|
||
text: '#5B5FED', thinking: '#8B5CF6', vision: '#219B5E', tool: '#3B82F6',
|
||
image: '#D9383A', audio: '#E8922C', asr: '#0EA5E9', ocr: '#F59E0B',
|
||
embedding: '#10B981', rerank: '#EC4899', video: '#EF4444',
|
||
};
|
||
|
||
const capabilityLabels: Record<string, string> = {
|
||
text: '文本对话', thinking: '推理思考', vision: '视觉理解', tool: '工具调用',
|
||
image: '图像生成', audio: '语音合成', asr: '语音识别', ocr: 'OCR识别',
|
||
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
|
||
};
|
||
|
||
// Test result type
|
||
interface TestResult {
|
||
modelId: string;
|
||
success: boolean;
|
||
message: string;
|
||
latency_ms?: number;
|
||
}
|
||
|
||
// Model editing type
|
||
interface EditingModel {
|
||
id?: string;
|
||
name: string;
|
||
provider_id: string;
|
||
api_key: string;
|
||
endpoint: string;
|
||
capabilities: string[];
|
||
timeout: number;
|
||
status: string;
|
||
price_per_million_tokens: number;
|
||
is_enabled: boolean;
|
||
}
|
||
|
||
const Models: React.FC = () => {
|
||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||
const [models, setModels] = useState<AIModel[]>([]);
|
||
const [protocols, setProtocols] = useState<ProtocolDef[]>([]);
|
||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||
const [fetchedModels, setFetchedModels] = useState<FetchableModel[]>([]);
|
||
const [fetchSource, setFetchSource] = useState<string>('');
|
||
const [fetchMessage, setFetchMessage] = useState<string>('');
|
||
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(new Set());
|
||
const [showAddProvider, setShowAddProvider] = useState(false);
|
||
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 能力默认模型(Auto 自动模式)
|
||
const [capabilities, setCapabilities] = useState<any[]>([]);
|
||
const [capabilityDefaults, setCapabilityDefaults] = useState<Record<string, string>>({});
|
||
const [capabilityModels, setCapabilityModels] = useState<any[]>([]);
|
||
const [savingCaps, setSavingCaps] = useState<Set<string>>(new Set());
|
||
|
||
// Model test state
|
||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||
const testTimer = useRef<number | null>(null);
|
||
|
||
// 测试反馈自动消失(6 秒后)
|
||
useEffect(() => {
|
||
return () => { if (testTimer.current) clearTimeout(testTimer.current); };
|
||
}, []);
|
||
|
||
// Model editor state
|
||
const [showModelEditor, setShowModelEditor] = useState(false);
|
||
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
|
||
const [modelSaving, setModelSaving] = useState(false);
|
||
|
||
const [providerForm, setProviderForm] = useState({
|
||
name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '',
|
||
});
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [provRes, modelsRes, protoRes, capRes] = await Promise.all([
|
||
adminAPI.getProviders(),
|
||
adminAPI.getAdminModels({ page_size: 100 }),
|
||
adminAPI.getProtocols(),
|
||
adminAPI.getCapabilityDefaults(),
|
||
]);
|
||
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
|
||
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
|
||
const modelData = modelsRes.data || modelsRes;
|
||
const rawModels = Array.isArray(modelData) ? modelData : (modelData?.items || []);
|
||
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
|
||
...m,
|
||
providerId: m.providerId || m.provider_id,
|
||
apiKey: m.apiKey || m.api_key,
|
||
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
|
||
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
|
||
isDefault: m.isDefault ?? m.is_default ?? false,
|
||
totalCalls: m.totalCalls ?? m.total_calls ?? 0,
|
||
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
|
||
}));
|
||
setModels(mappedModels);
|
||
// 能力默认模型
|
||
const cd = (capRes.data || capRes) as any;
|
||
setCapabilities(cd.capabilities || []);
|
||
setCapabilityDefaults(cd.defaults || {});
|
||
setCapabilityModels(cd.models || []);
|
||
} catch (err: any) {
|
||
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadData(); }, [loadData]);
|
||
|
||
// Test model connection (single model only)
|
||
const handleTestConnection = async (modelId: string) => {
|
||
setTestingModelId(modelId);
|
||
setTestResult(null);
|
||
if (testTimer.current) { clearTimeout(testTimer.current); testTimer.current = null; }
|
||
try {
|
||
const result = await adminAPI.testModelConnection(modelId) as any;
|
||
const r = result.success !== undefined ? result : (result.data || result);
|
||
setTestResult({ modelId, success: !!r.success, message: r.message || '', latency_ms: r.latency_ms });
|
||
} catch (err: any) {
|
||
setTestResult({ modelId, success: false, message: err.message || '测试失败' });
|
||
} finally {
|
||
setTestingModelId(null);
|
||
// 6 秒后自动收起反馈
|
||
testTimer.current = window.setTimeout(() => setTestResult(null), 6000);
|
||
}
|
||
};
|
||
|
||
// Set default model
|
||
const handleSetDefault = async (modelId: string) => {
|
||
try {
|
||
await adminAPI.setDefaultModel(modelId);
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '设置失败');
|
||
}
|
||
};
|
||
|
||
// Provider operations
|
||
const handleAddProvider = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!providerForm.name.trim()) return;
|
||
try {
|
||
setError(null);
|
||
await adminAPI.addProvider({
|
||
name: providerForm.name,
|
||
protocol: providerForm.protocol,
|
||
api_key: (providerForm as any).api_key || providerForm.api_key,
|
||
api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined,
|
||
});
|
||
setShowAddProvider(false);
|
||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '添加供应商失败');
|
||
}
|
||
};
|
||
|
||
const handleUpdateProvider = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!editingProvider || !providerForm.name.trim()) return;
|
||
try {
|
||
setError(null);
|
||
await adminAPI.updateProvider(editingProvider.id, {
|
||
name: providerForm.name, protocol: providerForm.protocol,
|
||
api_key: (providerForm as any).api_key || providerForm.api_key,
|
||
api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined,
|
||
});
|
||
setEditingProvider(null);
|
||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '更新供应商失败');
|
||
}
|
||
};
|
||
|
||
const handleDeleteProvider = async (id: string) => {
|
||
if (!confirm('确定删除该供应商?')) return;
|
||
try {
|
||
await adminAPI.deleteProvider(id);
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '删除失败');
|
||
}
|
||
};
|
||
|
||
// Fetch models from provider API
|
||
const handleFetchModels = async (providerId: string) => {
|
||
try {
|
||
const result = await adminAPI.fetchProviderModels(providerId) as any;
|
||
setFetchedModels(result.models || result.data?.models || []);
|
||
setFetchSource(result.source || result.data?.source || 'unknown');
|
||
setFetchMessage(result.message || result.data?.message || '');
|
||
setExpandedProvider(providerId);
|
||
setSelectedModelIds(new Set());
|
||
} catch (err: any) {
|
||
setError(err.message || '获取模型列表失败');
|
||
}
|
||
};
|
||
|
||
// Add selected models
|
||
const handleAddModels = async (providerId: string) => {
|
||
if (selectedModelIds.size === 0) return;
|
||
try {
|
||
await adminAPI.addModels(providerId, Array.from(selectedModelIds));
|
||
setExpandedProvider(null);
|
||
setSelectedModelIds(new Set());
|
||
setFetchedModels([]);
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '添加模型失败');
|
||
}
|
||
};
|
||
|
||
// Toggle model selection
|
||
const toggleModelSelection = (id: string) => {
|
||
const newSet = new Set(selectedModelIds);
|
||
if (newSet.has(id)) newSet.delete(id);
|
||
else newSet.add(id);
|
||
setSelectedModelIds(newSet);
|
||
};
|
||
|
||
// Open model editor
|
||
const openModelEditor = (model?: AIModel, providerId?: string) => {
|
||
if (model) {
|
||
setEditingModel({
|
||
id: model.id, name: model.name,
|
||
provider_id: (model as any).provider_id || model.providerId || providerId || '',
|
||
api_key: (model as any).api_key || model.apiKey || '',
|
||
endpoint: model.endpoint || '',
|
||
capabilities: model.capabilities || ['text'],
|
||
timeout: model.timeout || 60,
|
||
status: model.status || 'paid',
|
||
price_per_million_tokens: model.pricePerMillionTokens ?? 0,
|
||
is_enabled: model.isEnabled ?? true,
|
||
});
|
||
} else {
|
||
setEditingModel({
|
||
name: '', provider_id: providerId || '',
|
||
api_key: '', endpoint: '',
|
||
capabilities: ['text'], timeout: 60,
|
||
status: 'paid', price_per_million_tokens: 0, is_enabled: true,
|
||
});
|
||
}
|
||
setShowModelEditor(true);
|
||
};
|
||
|
||
// Save model
|
||
const handleSaveModel = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!editingModel || !editingModel.name.trim()) return;
|
||
setModelSaving(true);
|
||
try {
|
||
if (editingModel.id) {
|
||
await adminAPI.updateAdminModel(editingModel.id, editingModel as any);
|
||
} else {
|
||
await adminAPI.createAdminModel(editingModel as any);
|
||
}
|
||
setShowModelEditor(false);
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '保存失败');
|
||
} finally {
|
||
setModelSaving(false);
|
||
}
|
||
};
|
||
|
||
// Delete model
|
||
const handleDeleteModel = async (modelId: string) => {
|
||
if (!confirm('确定删除该模型?')) return;
|
||
try {
|
||
await adminAPI.deleteAdminModel(modelId);
|
||
await loadData();
|
||
} catch (err: any) {
|
||
setError(err.message || '删除失败');
|
||
}
|
||
};
|
||
|
||
// 设置/清除某个能力的默认模型(Auto 自动模式使用)
|
||
const handleSetCapabilityDefault = async (capId: string, modelId: string | null) => {
|
||
try {
|
||
setSavingCaps(prev => new Set(prev).add(capId));
|
||
const res = await adminAPI.setCapabilityDefault(capId, modelId) as any;
|
||
setCapabilityDefaults((res && res.defaults) || { ...capabilityDefaults, [capId]: modelId || undefined } as Record<string, string>);
|
||
} catch (err: any) {
|
||
setError(err.message || '设置失败');
|
||
} finally {
|
||
setSavingCaps(prev => {
|
||
const n = new Set(prev);
|
||
n.delete(capId);
|
||
return n;
|
||
});
|
||
}
|
||
};
|
||
|
||
// Toggle capability
|
||
const toggleCapability = (cap: string) => {
|
||
if (!editingModel) return;
|
||
const caps = editingModel.capabilities || [];
|
||
if (caps.includes(cap)) {
|
||
editingModel.capabilities = caps.filter(c => c !== cap);
|
||
} else {
|
||
editingModel.capabilities = [...caps, cap];
|
||
}
|
||
setEditingModel({ ...editingModel });
|
||
};
|
||
|
||
// Group models by provider
|
||
const getProviderModels = (providerId: string) =>
|
||
models.filter(m => ((m as any).provider_id || m.providerId) === providerId);
|
||
|
||
const getProviderName = (providerId: string) => {
|
||
const p = providers.find(p => p.id === providerId);
|
||
return p ? p.name : providerId;
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||
<div>
|
||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>大模型配置</h2>
|
||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: '13px' }}>
|
||
管理 AI 模型供应商、模型配置和连接测试
|
||
</p>
|
||
</div>
|
||
<button onClick={() => { setShowAddProvider(true); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); }}
|
||
style={btnStyle('#5B5FED')}>
|
||
+ 添加供应商
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
|
||
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div style={{ textAlign: 'center', padding: '60px', color: '#999' }}>加载中...</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||
{providers.map(provider => {
|
||
const provModels = getProviderModels(provider.id);
|
||
const isExpanded = expandedProvider === provider.id;
|
||
|
||
return (
|
||
<div key={provider.id} style={{ background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||
{/* Provider Header */}
|
||
<div style={{ padding: '16px 20px', background: '#F9FAFB', borderBottom: '1px solid #E5E7EB', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<span style={{ fontWeight: 600, fontSize: '15px' }}>{provider.name}</span>
|
||
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#EEF2FF', color: '#5B5FED', borderRadius: '4px' }}>
|
||
{provider.protocol}
|
||
</span>
|
||
{provModels.filter(m => m.isDefault).length > 0 && (
|
||
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#DCFCE7', color: '#16A34A', borderRadius: '4px' }}>默认</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'}
|
||
{(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px' }}>
|
||
<button onClick={() => handleFetchModels(provider.id)} style={btnStyle('#6B7280', 'small')}>获取模型</button>
|
||
<button onClick={() => { setEditingProvider(provider); setProviderForm({ name: provider.name, protocol: provider.protocol,
|
||
api_base_url: (provider as any).api_base_url || provider.apiBaseUrl || '',
|
||
api_key: (provider as any).api_key || provider.apiKey || '' }); setShowAddProvider(true); }} style={btnStyle('#6B7280', 'small')}>编辑</button>
|
||
<button onClick={() => handleDeleteProvider(provider.id)} style={btnStyle('#EF4444', 'small')}>删除</button>
|
||
<button onClick={() => openModelEditor(undefined, provider.id)} style={btnStyle('#5B5FED', 'small')}>+ 添加模型</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Model Fetch Panel */}
|
||
{isExpanded && (
|
||
<div style={{ padding: '16px 20px', background: '#FFFBEB', borderBottom: '1px solid #FDE68A' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||
<span style={{ fontSize: '13px', color: '#92400E' }}>
|
||
{fetchMessage || `找到 ${fetchedModels.length} 个模型(来源: ${fetchSource})`}
|
||
</span>
|
||
{fetchedModels.length > 0 && (
|
||
<button onClick={() => handleAddModels(provider.id)} style={btnStyle('#5B5FED', 'small')} disabled={selectedModelIds.size === 0}>
|
||
添加选中 ({selectedModelIds.size})
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||
{fetchedModels.map(m => (
|
||
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', cursor: 'pointer', padding: '4px 8px', background: selectedModelIds.has(m.id) ? '#EDE9FE' : '#fff', border: '1px solid #D1D5DB', borderRadius: '4px' }}>
|
||
<input type="checkbox" checked={selectedModelIds.has(m.id)} onChange={() => toggleModelSelection(m.id)} />
|
||
{m.name || m.id}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Models List */}
|
||
<div style={{ padding: '0' }}>
|
||
{provModels.length === 0 ? (
|
||
<div style={{ padding: '24px', textAlign: 'center', color: '#999', fontSize: '13px' }}>
|
||
暂无模型,点击"获取模型"或"添加模型"添加
|
||
</div>
|
||
) : (
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ background: '#F3F4F6' }}>
|
||
<th style={thStyle}>模型</th>
|
||
<th style={thStyle}>能力</th>
|
||
<th style={thStyle}>状态</th>
|
||
<th style={thStyle}>价格</th>
|
||
<th style={thStyle}>调用量</th>
|
||
<th style={thStyle}>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{provModels.map(model => (
|
||
<tr key={model.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||
<td style={tdStyle}>
|
||
<div style={{ fontWeight: 500 }}>{model.name}</div>
|
||
{model.isDefault && <span style={{ fontSize: '11px', color: '#16A34A' }}>⭐ 默认模型</span>}
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||
{(model.capabilities || []).map(cap => (
|
||
<span key={cap} style={{ fontSize: '11px', padding: '2px 6px', background: capabilityColors[cap] + '22', color: capabilityColors[cap], borderRadius: '3px' }}>
|
||
{capabilityLabels[cap] || cap}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<span style={{ fontSize: '12px', padding: '2px 8px', background: model.isEnabled ? '#DCFCE7' : '#FEE2E2', color: model.isEnabled ? '#16A34A' : '#DC2626', borderRadius: '4px' }}>
|
||
{model.isEnabled ? '启用' : '禁用'}
|
||
</span>
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||
¥{model.pricePerMillionTokens?.toFixed(4) || '0.0000'}/M
|
||
</span>
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||
{formatNumber(model.totalCalls || 0)}
|
||
</span>
|
||
</td>
|
||
<td style={tdStyle}>
|
||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||
{/* Test Connection Button */}
|
||
<button onClick={() => handleTestConnection(model.id)} disabled={testingModelId === model.id}
|
||
style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}>
|
||
{testingModelId === model.id ? '测试中...' : '连接测试'}
|
||
</button>
|
||
{/* Test Result Popover (only for the tested model) */}
|
||
{testResult && testResult.modelId === model.id && (
|
||
<div style={{ position: 'relative' }}>
|
||
<span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px', display: 'inline-block' }}>
|
||
{testResult.success ? '✓ 成功' : '✗ 失败'}
|
||
{testResult.latency_ms && ` (${testResult.latency_ms}ms)`}
|
||
</span>
|
||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10, background: '#fff', border: '1px solid #E5E7EB', borderRadius: '6px', padding: '8px 12px', fontSize: '12px', color: '#333', minWidth: '200px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', marginTop: '4px' }}>
|
||
{testResult.message}
|
||
</div>
|
||
<button onClick={() => setTestResult(null)} style={{ marginLeft: '8px', fontSize: '11px', color: '#999', background: 'none', border: 'none', cursor: 'pointer' }}>×</button>
|
||
</div>
|
||
)}
|
||
{!model.isDefault && model.isEnabled && (
|
||
<button onClick={() => handleSetDefault(model.id)} style={btnStyle('#5B5FED', 'small')}>设为默认</button>
|
||
)}
|
||
<button onClick={() => openModelEditor(model as AIModel)} style={btnStyle('#6B7280', 'small')}>编辑</button>
|
||
<button onClick={() => handleDeleteModel(model.id)} style={btnStyle('#EF4444', 'small')}>删除</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{providers.length === 0 && (
|
||
<div style={{ textAlign: 'center', padding: '60px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }}>
|
||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
|
||
<p style={{ color: '#666', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== 能力默认模型(Auto 自动模式) ===== */}
|
||
{capabilities.length > 0 && (
|
||
<div style={{ background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', padding: '20px', marginTop: '16px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '4px', flexWrap: 'wrap', gap: '8px' }}>
|
||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>能力默认模型</h3>
|
||
<span style={{ fontSize: '12px', color: '#888' }}>
|
||
为每项能力指定默认模型,前台「Auto 自动」将按对话意图自动匹配
|
||
</span>
|
||
</div>
|
||
<p style={{ margin: '0 0 16px', fontSize: '13px', color: '#999' }}>
|
||
选择「Auto 自动」时,系统依据用户输入(如含「画图 / 视频」)自动选用对应能力的默认模型;文本类能力在未单独配置时回退到全局默认模型。
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' }}>
|
||
{capabilities.map((cap: any) => {
|
||
const capModels = capabilityModels.filter(
|
||
(m: any) => (m.capabilities || []).includes(cap.id) || capabilityDefaults[cap.id] === m.id
|
||
);
|
||
const cur = capabilityDefaults[cap.id] || '';
|
||
const saving = savingCaps.has(cap.id);
|
||
const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280';
|
||
return (
|
||
<div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
|
||
<span style={{ width: '10px', height: '10px', borderRadius: '3px', background: dotColor, flexShrink: 0 }} />
|
||
<span style={{ fontWeight: 600, fontSize: '14px' }}>{cap.label}</span>
|
||
<span style={{ fontSize: '11px', color: '#bbb', marginLeft: 'auto' }}>{cap.id}</span>
|
||
</div>
|
||
<div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
|
||
<select
|
||
value={cur}
|
||
disabled={saving}
|
||
onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)}
|
||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }}
|
||
>
|
||
<option value="">— 未设置(回退全局默认)—</option>
|
||
{capModels.map((m: any) => (
|
||
<option key={m.id} value={m.id}>
|
||
{m.display_name || m.name}{m.provider_name ? `(${m.provider_name})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Provider Modal */}
|
||
{showAddProvider && (
|
||
<div style={modalOverlay}>
|
||
<div style={modalContent}>
|
||
<h3 style={{ marginTop: 0 }}>{editingProvider ? '编辑供应商' : '添加供应商'}</h3>
|
||
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>供应商名称 *</label>
|
||
<input value={providerForm.name} onChange={e => setProviderForm({ ...providerForm, name: e.target.value })} placeholder="如:SiliconFlow" style={inputStyle} required />
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>API 协议</label>
|
||
<select value={providerForm.protocol} onChange={e => setProviderForm({ ...providerForm, protocol: e.target.value })} style={inputStyle}>
|
||
{protocols.map(p => <option key={p.id} value={p.id}>{p.name} ({p.id})</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>API Base URL</label>
|
||
<input value={providerForm.api_base_url} onChange={e => setProviderForm({ ...providerForm, api_base_url: e.target.value })} placeholder="https://api.siliconflow.cn" style={inputStyle} />
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>API Key</label>
|
||
<input type="password" value={providerForm.api_key} onChange={e => setProviderForm({ ...providerForm, api_key: e.target.value })} placeholder="sk-..." style={inputStyle} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||
<button type="button" onClick={() => { setShowAddProvider(false); setEditingProvider(null); }} style={btnStyle('#6B7280')}>取消</button>
|
||
<button type="submit" style={btnStyle('#5B5FED')}>{editingProvider ? '保存' : '添加'}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Model Editor Modal */}
|
||
{showModelEditor && editingModel && (
|
||
<div style={modalOverlay}>
|
||
<div style={{ ...modalContent, maxWidth: '600px' }}>
|
||
<h3 style={{ marginTop: 0 }}>{editingModel.id ? '编辑模型' : '添加模型'}</h3>
|
||
<form onSubmit={handleSaveModel}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>模型名称 *</label>
|
||
<input value={editingModel.name} onChange={e => setEditingModel({ ...editingModel, name: e.target.value })} placeholder="如:DeepSeek-V4-Flash" style={inputStyle} required />
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>供应商</label>
|
||
<select value={editingModel.provider_id} onChange={e => setEditingModel({ ...editingModel, provider_id: e.target.value })} style={inputStyle}>
|
||
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>API Key</label>
|
||
<input type="password" value={editingModel.api_key} onChange={e => setEditingModel({ ...editingModel, api_key: e.target.value })} placeholder="sk-... (留空则使用供应商的Key)" style={inputStyle} />
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>API Endpoint</label>
|
||
<input value={editingModel.endpoint} onChange={e => setEditingModel({ ...editingModel, endpoint: e.target.value })} placeholder="https://api.siliconflow.cn/v1/chat/completions" style={inputStyle} />
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>模型能力 (可多选)</label>
|
||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
{Object.entries(capabilityLabels).map(([key, label]) => (
|
||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: (editingModel.capabilities || []).includes(key) ? capabilityColors[key] + '22' : '#F3F4F6', border: `1px solid ${(editingModel.capabilities || []).includes(key) ? capabilityColors[key] : '#D1D5DB'}`, borderRadius: '4px', fontSize: '13px' }}>
|
||
<input type="checkbox" checked={(editingModel.capabilities || []).includes(key)} onChange={() => toggleCapability(key)} style={{ display: 'none' }} />
|
||
<span style={{ color: capabilityColors[key] }}>●</span> {label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>超时 (秒)</label>
|
||
<input type="number" value={editingModel.timeout} onChange={e => setEditingModel({ ...editingModel, timeout: parseInt(e.target.value) || 60 })} style={inputStyle} />
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>计费状态</label>
|
||
<select value={editingModel.status} onChange={e => setEditingModel({ ...editingModel, status: e.target.value })} style={inputStyle}>
|
||
<option value="free">免费</option>
|
||
<option value="paid">付费</option>
|
||
</select>
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={labelStyle}>价格 (¥/M tokens)</label>
|
||
<input type="number" step="0.0001" value={editingModel.price_per_million_tokens} onChange={e => setEditingModel({ ...editingModel, price_per_million_tokens: parseFloat(e.target.value) || 0 })} style={inputStyle} />
|
||
</div>
|
||
</div>
|
||
<div style={formGroup}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={editingModel.is_enabled} onChange={e => setEditingModel({ ...editingModel, is_enabled: e.target.checked })} />
|
||
启用该模型
|
||
</label>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||
<button type="button" onClick={() => setShowModelEditor(false)} style={btnStyle('#6B7280')}>取消</button>
|
||
<button type="submit" disabled={modelSaving} style={btnStyle('#5B5FED')}>{modelSaving ? '保存中...' : '保存'}</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Styles
|
||
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
|
||
background: color, color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer',
|
||
fontSize: size === 'small' ? '12px' : '14px', padding: size === 'small' ? '4px 10px' : '8px 16px',
|
||
fontWeight: 500, transition: 'opacity 0.2s',
|
||
});
|
||
|
||
const thStyle: React.CSSProperties = { padding: '10px 16px', textAlign: 'left', fontSize: '12px', color: '#6B7280', fontWeight: 600 };
|
||
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px', verticalAlign: 'middle' };
|
||
const formGroup: React.CSSProperties = { marginBottom: '16px' };
|
||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '6px', fontSize: '13px', fontWeight: 500, color: '#374151' };
|
||
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box' };
|
||
const alertStyle = (bg: string, color: string): React.CSSProperties => ({ background: bg, color, padding: '10px 16px', borderRadius: '6px', fontSize: '13px' });
|
||
const modalOverlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
|
||
const modalContent: React.CSSProperties = { background: '#fff', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', boxSizing: 'border-box' };
|
||
|
||
export default Models;
|