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:
donald
2025-12-06 00:33:10 +08:00
parent 30e39b5c6f
commit 957003bc7c
10 changed files with 1564 additions and 16 deletions

View File

@@ -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({

View File

@@ -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
// ============================================