feat: Add multi-LLM provider support with DeepSeek integration
Major Features: - ✨ Multi-LLM provider support (DeepSeek, Ollama, OpenAI, Custom) - 🤖 Admin panel LLM configuration management UI - 🔄 Dynamic provider switching without restart - 🧪 Built-in API connection testing - 🔒 Secure API key management Backend Changes: - Add routes/llmConfig.js: Complete LLM config CRUD API - Update routes/analyze.js: Use database LLM configuration - Update server.js: Add LLM config routes - Add scripts/add-deepseek-config.js: DeepSeek setup script Frontend Changes: - Update src/pages/AdminPage.jsx: Add LLM Config tab + modal - Update src/services/api.js: Add LLM config API methods - Provider presets for DeepSeek, Ollama, OpenAI - Test connection feature in config modal Configuration: - Update .env.example: Add DeepSeek API configuration - Update package.json: Add llm:add-deepseek script Documentation: - Add docs/LLM_CONFIGURATION_GUIDE.md: Complete guide - Add DEEPSEEK_INTEGRATION.md: Integration summary - Quick setup instructions for DeepSeek API Endpoints: - GET /api/llm-config: List all configurations - GET /api/llm-config/active: Get active configuration - POST /api/llm-config: Create configuration - PUT /api/llm-config/🆔 Update configuration - PUT /api/llm-config/:id/activate: Activate configuration - DELETE /api/llm-config/🆔 Delete configuration - POST /api/llm-config/test: Test API connection Database: - Uses existing llm_configs table - Only one config active at a time - Fallback to Ollama if no database config Security: - Admin-only access to LLM configuration - API keys never returned in GET requests - Audit logging for all config changes - Cannot delete active configuration DeepSeek Model: - Model: deepseek-chat - High-quality 5 Why analysis - Excellent Chinese language support - Cost-effective pricing 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ export default function AdminPage() {
|
||||
{ id: 'dashboard', name: '總覽', icon: '📊' },
|
||||
{ id: 'users', name: '使用者管理', icon: '👥' },
|
||||
{ id: 'analyses', name: '分析記錄', icon: '📝' },
|
||||
{ id: 'llm', name: 'LLM 配置', icon: '🤖' },
|
||||
{ id: 'audit', name: '稽核日誌', icon: '🔍' },
|
||||
].map(tab => (
|
||||
<button
|
||||
@@ -50,6 +51,7 @@ export default function AdminPage() {
|
||||
{activeTab === 'dashboard' && <DashboardTab />}
|
||||
{activeTab === 'users' && <UsersTab />}
|
||||
{activeTab === 'analyses' && <AnalysesTab />}
|
||||
{activeTab === 'llm' && <LLMConfigTab />}
|
||||
{activeTab === 'audit' && <AuditTab />}
|
||||
</div>
|
||||
);
|
||||
@@ -366,6 +368,368 @@ function AuditTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// LLM Config Tab Component
|
||||
function LLMConfigTab() {
|
||||
const [configs, setConfigs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const response = await api.getLLMConfigs();
|
||||
if (response.success) {
|
||||
setConfigs(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activateConfig = async (id) => {
|
||||
try {
|
||||
await api.activateLLMConfig(id);
|
||||
loadConfigs();
|
||||
} catch (err) {
|
||||
alert('啟用失敗: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteConfig = async (id) => {
|
||||
if (!confirm('確定要刪除此 LLM 配置嗎?')) return;
|
||||
|
||||
try {
|
||||
await api.deleteLLMConfig(id);
|
||||
loadConfigs();
|
||||
} catch (err) {
|
||||
alert('刪除失敗: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-12">載入中...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">LLM 配置管理</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">配置 AI 模型 (DeepSeek, Ollama 等)</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfig(null);
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
➕ 新增配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">提供商</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">API 端點</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">模型</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">狀態</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">建立時間</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{configs.map((config) => (
|
||||
<tr key={config.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900">{config.provider_name}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
|
||||
{config.api_endpoint}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{config.model_name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{config.is_active ? (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-700">
|
||||
啟用中
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-700">
|
||||
未啟用
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(config.created_at).toLocaleString('zh-TW')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
|
||||
{!config.is_active && (
|
||||
<button
|
||||
onClick={() => activateConfig(config.id)}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
啟用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingConfig(config);
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
{!config.is_active && (
|
||||
<button
|
||||
onClick={() => deleteConfig(config.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<LLMConfigModal
|
||||
config={editingConfig}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false);
|
||||
loadConfigs();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LLM Config Modal
|
||||
function LLMConfigModal({ config, onClose, onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
provider_name: config?.provider_name || 'DeepSeek',
|
||||
api_endpoint: config?.api_endpoint || 'https://api.deepseek.com',
|
||||
api_key: '',
|
||||
model_name: config?.model_name || 'deepseek-chat',
|
||||
temperature: config?.temperature || 0.7,
|
||||
max_tokens: config?.max_tokens || 6000,
|
||||
timeout_seconds: config?.timeout_seconds || 120,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [testResult, setTestResult] = useState('');
|
||||
|
||||
const providerPresets = {
|
||||
DeepSeek: {
|
||||
api_endpoint: 'https://api.deepseek.com',
|
||||
model_name: 'deepseek-chat',
|
||||
},
|
||||
Ollama: {
|
||||
api_endpoint: 'https://ollama_pjapi.theaken.com',
|
||||
model_name: 'qwen2.5:3b',
|
||||
},
|
||||
OpenAI: {
|
||||
api_endpoint: 'https://api.openai.com',
|
||||
model_name: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
const handleProviderChange = (provider) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
provider_name: provider,
|
||||
...(providerPresets[provider] || {}),
|
||||
});
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await api.testLLMConfig({
|
||||
api_endpoint: formData.api_endpoint,
|
||||
api_key: formData.api_key,
|
||||
model_name: formData.model_name,
|
||||
});
|
||||
setTestResult('✅ 連線測試成功!');
|
||||
} catch (err) {
|
||||
setError('連線測試失敗: ' + err.message);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (config) {
|
||||
await api.updateLLMConfig(config.id, formData);
|
||||
} else {
|
||||
await api.createLLMConfig(formData);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-xl font-bold mb-4">
|
||||
{config ? '編輯 LLM 配置' : '新增 LLM 配置'}
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded mb-4 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-3 py-2 rounded mb-4 text-sm">
|
||||
{testResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">提供商 *</label>
|
||||
<select
|
||||
value={formData.provider_name}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
required
|
||||
>
|
||||
<option value="DeepSeek">DeepSeek</option>
|
||||
<option value="Ollama">Ollama</option>
|
||||
<option value="OpenAI">OpenAI</option>
|
||||
<option value="Other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">API 端點 *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.api_endpoint}
|
||||
onChange={(e) => setFormData({...formData, api_endpoint: e.target.value})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="https://api.deepseek.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData({...formData, api_key: e.target.value})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="選填(某些 API 需要)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">模型名稱 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData({...formData, model_name: e.target.value})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
placeholder="deepseek-chat"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Temperature</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={formData.temperature}
|
||||
onChange={(e) => setFormData({...formData, temperature: parseFloat(e.target.value)})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Max Tokens</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.max_tokens}
|
||||
onChange={(e) => setFormData({...formData, max_tokens: parseInt(e.target.value)})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Timeout (秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.timeout_seconds}
|
||||
onChange={(e) => setFormData({...formData, timeout_seconds: parseInt(e.target.value)})}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
className="px-4 py-2 border border-indigo-600 text-indigo-600 rounded-lg hover:bg-indigo-50 disabled:opacity-50"
|
||||
>
|
||||
{testing ? '測試中...' : '🔍 測試連線'}
|
||||
</button>
|
||||
<div className="flex-1"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '儲存中...' : '儲存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create User Modal
|
||||
function CreateUserModal({ onClose, onSuccess }) {
|
||||
const [formData, setFormData] = useState({
|
||||
|
||||
@@ -170,6 +170,38 @@ class ApiClient {
|
||||
return this.get('/api/admin/statistics');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Configuration APIs
|
||||
// ============================================
|
||||
|
||||
async getLLMConfigs() {
|
||||
return this.get('/api/llm-config');
|
||||
}
|
||||
|
||||
async getActiveLLMConfig() {
|
||||
return this.get('/api/llm-config/active');
|
||||
}
|
||||
|
||||
async createLLMConfig(configData) {
|
||||
return this.post('/api/llm-config', configData);
|
||||
}
|
||||
|
||||
async updateLLMConfig(id, configData) {
|
||||
return this.put(`/api/llm-config/${id}`, configData);
|
||||
}
|
||||
|
||||
async activateLLMConfig(id) {
|
||||
return this.put(`/api/llm-config/${id}/activate`, {});
|
||||
}
|
||||
|
||||
async deleteLLMConfig(id) {
|
||||
return this.delete(`/api/llm-config/${id}`);
|
||||
}
|
||||
|
||||
async testLLMConfig(configData) {
|
||||
return this.post('/api/llm-config/test', configData);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Health Check
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user