CodeReview/frontend/src/components/system/SystemConfig.tsx

482 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Settings, Save, RotateCcw, Eye, EyeOff, CheckCircle2, AlertCircle,
Info, Zap, Globe, PlayCircle, Loader2
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/shared/api/database";
// LLM 提供商配置 - 2025年最新
const LLM_PROVIDERS = [
{ value: 'openai', label: 'OpenAI GPT', icon: '🟢', category: 'litellm', hint: 'gpt-5, gpt-5-mini, o3 等' },
{ value: 'claude', label: 'Anthropic Claude', icon: '🟣', category: 'litellm', hint: 'claude-sonnet-4.5, claude-opus-4 等' },
{ value: 'gemini', label: 'Google Gemini', icon: '🔵', category: 'litellm', hint: 'gemini-3-pro, gemini-3-flash 等' },
{ value: 'deepseek', label: 'DeepSeek', icon: '🔷', category: 'litellm', hint: 'deepseek-v3.1-terminus, deepseek-v3 等' },
{ value: 'qwen', label: '通义千问', icon: '🟠', category: 'litellm', hint: 'qwen3-max-instruct, qwen3-plus 等' },
{ value: 'zhipu', label: '智谱AI (GLM)', icon: '🔴', category: 'litellm', hint: 'glm-4.6, glm-4.5-flash 等' },
{ value: 'moonshot', label: 'Moonshot (Kimi)', icon: '🌙', category: 'litellm', hint: 'kimi-k2, kimi-k1.5 等' },
{ value: 'ollama', label: 'Ollama 本地', icon: '🖥️', category: 'litellm', hint: 'llama3.3-70b, qwen3-8b 等' },
{ value: 'baidu', label: '百度文心', icon: '📘', category: 'native', hint: 'ernie-4.5 (需要 API_KEY:SECRET_KEY)' },
{ value: 'minimax', label: 'MiniMax', icon: '⚡', category: 'native', hint: 'minimax-m2, minimax-m1 等' },
{ value: 'doubao', label: '字节豆包', icon: '🎯', category: 'native', hint: 'doubao-1.6-pro, doubao-1.5-pro 等' },
];
const DEFAULT_MODELS: Record<string, string> = {
openai: 'gpt-5', claude: 'claude-sonnet-4.5', gemini: 'gemini-3-pro',
deepseek: 'deepseek-v3.1-terminus', qwen: 'qwen3-max-instruct', zhipu: 'glm-4.6', moonshot: 'kimi-k2',
ollama: 'llama3.3-70b', baidu: 'ernie-4.5', minimax: 'minimax-m2', doubao: 'doubao-1.6-pro',
};
interface SystemConfigData {
llmProvider: string; llmApiKey: string; llmModel: string; llmBaseUrl: string;
llmTimeout: number; llmTemperature: number; llmMaxTokens: number;
githubToken: string; gitlabToken: string;
maxAnalyzeFiles: number; llmConcurrency: number; llmGapMs: number; outputLanguage: string;
}
export function SystemConfig() {
const [config, setConfig] = useState<SystemConfigData | null>(null);
const [loading, setLoading] = useState(true);
const [showApiKey, setShowApiKey] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [testingLLM, setTestingLLM] = useState(false);
const [llmTestResult, setLlmTestResult] = useState<{ success: boolean; message: string } | null>(null);
useEffect(() => { loadConfig(); }, []);
const loadConfig = async () => {
try {
setLoading(true);
console.log('[SystemConfig] 开始加载配置...');
// 后端 /config/me 已经返回合并后的配置(用户配置优先,然后是系统默认配置)
const backendConfig = await api.getUserConfig();
console.log('[SystemConfig] 后端返回的原始数据:', JSON.stringify(backendConfig, null, 2));
if (backendConfig) {
// 直接使用后端返回的合并配置
const llmConfig = backendConfig.llmConfig || {};
const otherConfig = backendConfig.otherConfig || {};
const newConfig = {
llmProvider: llmConfig.llmProvider || 'openai',
llmApiKey: llmConfig.llmApiKey || '',
llmModel: llmConfig.llmModel || '',
llmBaseUrl: llmConfig.llmBaseUrl || '',
llmTimeout: llmConfig.llmTimeout || 150000,
llmTemperature: llmConfig.llmTemperature ?? 0.1,
llmMaxTokens: llmConfig.llmMaxTokens || 4096,
githubToken: otherConfig.githubToken || '',
gitlabToken: otherConfig.gitlabToken || '',
maxAnalyzeFiles: otherConfig.maxAnalyzeFiles || 50,
llmConcurrency: otherConfig.llmConcurrency || 3,
llmGapMs: otherConfig.llmGapMs || 2000,
outputLanguage: otherConfig.outputLanguage || 'zh-CN',
};
console.log('[SystemConfig] 解析后的配置:', newConfig);
setConfig(newConfig);
console.log('✓ 配置已加载:', {
provider: llmConfig.llmProvider,
hasApiKey: !!llmConfig.llmApiKey,
model: llmConfig.llmModel,
});
} else {
console.warn('[SystemConfig] 后端返回空数据,使用默认配置');
// 如果获取失败,使用默认值
setConfig({
llmProvider: 'openai', llmApiKey: '', llmModel: '', llmBaseUrl: '',
llmTimeout: 150000, llmTemperature: 0.1, llmMaxTokens: 4096,
githubToken: '', gitlabToken: '',
maxAnalyzeFiles: 50, llmConcurrency: 3, llmGapMs: 2000, outputLanguage: 'zh-CN',
});
}
} catch (error) {
console.error('Failed to load config:', error);
setConfig({
llmProvider: 'openai', llmApiKey: '', llmModel: '', llmBaseUrl: '',
llmTimeout: 150000, llmTemperature: 0.1, llmMaxTokens: 4096,
githubToken: '', gitlabToken: '',
maxAnalyzeFiles: 50, llmConcurrency: 3, llmGapMs: 2000, outputLanguage: 'zh-CN',
});
} finally {
setLoading(false);
}
};
const saveConfig = async () => {
if (!config) return;
try {
const savedConfig = await api.updateUserConfig({
llmConfig: {
llmProvider: config.llmProvider, llmApiKey: config.llmApiKey,
llmModel: config.llmModel, llmBaseUrl: config.llmBaseUrl,
llmTimeout: config.llmTimeout, llmTemperature: config.llmTemperature,
llmMaxTokens: config.llmMaxTokens,
},
otherConfig: {
githubToken: config.githubToken, gitlabToken: config.gitlabToken,
maxAnalyzeFiles: config.maxAnalyzeFiles, llmConcurrency: config.llmConcurrency,
llmGapMs: config.llmGapMs, outputLanguage: config.outputLanguage,
},
});
// 使用后端返回的数据更新本地状态,确保数据同步
if (savedConfig) {
const llmConfig = savedConfig.llmConfig || {};
const otherConfig = savedConfig.otherConfig || {};
setConfig({
llmProvider: llmConfig.llmProvider || config.llmProvider,
llmApiKey: llmConfig.llmApiKey || '',
llmModel: llmConfig.llmModel || '',
llmBaseUrl: llmConfig.llmBaseUrl || '',
llmTimeout: llmConfig.llmTimeout || 150000,
llmTemperature: llmConfig.llmTemperature ?? 0.1,
llmMaxTokens: llmConfig.llmMaxTokens || 4096,
githubToken: otherConfig.githubToken || '',
gitlabToken: otherConfig.gitlabToken || '',
maxAnalyzeFiles: otherConfig.maxAnalyzeFiles || 50,
llmConcurrency: otherConfig.llmConcurrency || 3,
llmGapMs: otherConfig.llmGapMs || 2000,
outputLanguage: otherConfig.outputLanguage || 'zh-CN',
});
}
setHasChanges(false);
toast.success("配置已保存!");
} catch (error) {
toast.error(`保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
const resetConfig = async () => {
if (!window.confirm("确定要重置为默认配置吗?")) return;
try {
await api.deleteUserConfig();
await loadConfig();
setHasChanges(false);
toast.success("已重置为默认配置");
} catch (error) {
toast.error(`重置失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
};
const updateConfig = (key: keyof SystemConfigData, value: string | number) => {
if (!config) return;
setConfig(prev => prev ? { ...prev, [key]: value } : null);
setHasChanges(true);
};
const testLLMConnection = async () => {
if (!config) return;
if (!config.llmApiKey && config.llmProvider !== 'ollama') {
toast.error('请先配置 API Key');
return;
}
setTestingLLM(true);
setLlmTestResult(null);
try {
const result = await api.testLLMConnection({
provider: config.llmProvider,
apiKey: config.llmApiKey,
model: config.llmModel || undefined,
baseUrl: config.llmBaseUrl || undefined,
});
setLlmTestResult(result);
if (result.success) toast.success(`连接成功!模型: ${result.model}`);
else toast.error(`连接失败: ${result.message}`);
} catch (error) {
const msg = error instanceof Error ? error.message : '未知错误';
setLlmTestResult({ success: false, message: msg });
toast.error(`测试失败: ${msg}`);
} finally {
setTestingLLM(false);
}
};
if (loading || !config) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-none h-12 w-12 border-4 border-black border-t-transparent mx-auto mb-4"></div>
<p className="text-black font-mono font-bold uppercase">...</p>
</div>
</div>
);
}
const currentProvider = LLM_PROVIDERS.find(p => p.value === config.llmProvider);
const isConfigured = config.llmApiKey !== '' || config.llmProvider === 'ollama';
return (
<div className="space-y-6">
{/* 状态栏 */}
<div className="bg-blue-50 border-2 border-blue-500 p-4 flex items-center justify-between shadow-[4px_4px_0px_0px_rgba(59,130,246,1)]">
<div className="flex items-center gap-4 font-mono text-sm">
<Info className="h-5 w-5 text-blue-600" />
<span className="font-bold">
{isConfigured ? (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-4 w-4" /> LLM ({currentProvider?.label})
</span>
) : (
<span className="text-orange-600 flex items-center gap-1">
<AlertCircle className="h-4 w-4" /> LLM API Key
</span>
)}
</span>
</div>
<div className="flex gap-2">
{hasChanges && (
<Button onClick={saveConfig} size="sm" className="retro-btn bg-black text-white border-2 border-black hover:bg-gray-800 rounded-none h-8 font-bold uppercase">
<Save className="w-3 h-3 mr-2" />
</Button>
)}
<Button onClick={resetConfig} variant="outline" size="sm" className="retro-btn bg-white text-black border-2 border-black hover:bg-gray-100 rounded-none h-8 font-bold uppercase">
<RotateCcw className="w-3 h-3 mr-2" />
</Button>
</div>
</div>
<Tabs defaultValue="llm" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-transparent border-2 border-black p-0 h-auto gap-0 mb-6">
<TabsTrigger value="llm" className="rounded-none border-r-2 border-black data-[state=active]:bg-black data-[state=active]:text-white font-mono font-bold uppercase h-10 text-xs">
<Zap className="w-3 h-3 mr-2" /> LLM
</TabsTrigger>
<TabsTrigger value="analysis" className="rounded-none border-r-2 border-black data-[state=active]:bg-black data-[state=active]:text-white font-mono font-bold uppercase h-10 text-xs">
<Settings className="w-3 h-3 mr-2" />
</TabsTrigger>
<TabsTrigger value="git" className="rounded-none data-[state=active]:bg-black data-[state=active]:text-white font-mono font-bold uppercase h-10 text-xs">
<Globe className="w-3 h-3 mr-2" /> Git
</TabsTrigger>
</TabsList>
{/* LLM 配置 - 简化版 */}
<TabsContent value="llm" className="space-y-6">
<div className="retro-card bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] p-6 space-y-6">
{/* 提供商选择 */}
<div className="space-y-2">
<Label className="font-mono font-bold uppercase"> LLM </Label>
<Select value={config.llmProvider} onValueChange={(v) => updateConfig('llmProvider', v)}>
<SelectTrigger className="h-12 bg-gray-50 border-2 border-black rounded-none font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-2 border-black rounded-none">
<div className="px-2 py-1.5 text-xs font-bold text-gray-500 uppercase">LiteLLM ()</div>
{LLM_PROVIDERS.filter(p => p.category === 'litellm').map(p => (
<SelectItem key={p.value} value={p.value} className="font-mono">
<span className="flex items-center gap-2">
<span>{p.icon}</span>
<span>{p.label}</span>
<span className="text-xs text-gray-400">- {p.hint}</span>
</span>
</SelectItem>
))}
<div className="px-2 py-1.5 text-xs font-bold text-gray-500 uppercase mt-2"></div>
{LLM_PROVIDERS.filter(p => p.category === 'native').map(p => (
<SelectItem key={p.value} value={p.value} className="font-mono">
<span className="flex items-center gap-2">
<span>{p.icon}</span>
<span>{p.label}</span>
<span className="text-xs text-gray-400">- {p.hint}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* API Key */}
{config.llmProvider !== 'ollama' && (
<div className="space-y-2">
<Label className="font-mono font-bold uppercase">API Key</Label>
<div className="flex gap-2">
<Input
type={showApiKey ? 'text' : 'password'}
value={config.llmApiKey}
onChange={(e) => updateConfig('llmApiKey', e.target.value)}
placeholder={config.llmProvider === 'baidu' ? 'API_KEY:SECRET_KEY 格式' : '输入你的 API Key'}
className="h-12 bg-gray-50 border-2 border-black rounded-none font-mono"
/>
<Button variant="outline" size="icon" onClick={() => setShowApiKey(!showApiKey)}
className="h-12 w-12 border-2 border-black rounded-none">
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
)}
{/* 模型和 Base URL */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="font-mono font-bold uppercase"> ()</Label>
<Input
value={config.llmModel}
onChange={(e) => updateConfig('llmModel', e.target.value)}
placeholder={`默认: ${DEFAULT_MODELS[config.llmProvider] || 'auto'}`}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono"
/>
</div>
<div className="space-y-2">
<Label className="font-mono font-bold uppercase">API Base URL ()</Label>
<Input
value={config.llmBaseUrl}
onChange={(e) => updateConfig('llmBaseUrl', e.target.value)}
placeholder="留空使用官方地址,或填入中转站地址"
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono"
/>
</div>
</div>
{/* 测试连接 */}
<div className="pt-4 border-t-2 border-black border-dashed flex items-center justify-between">
<div className="text-sm font-mono">
<span className="font-bold"></span>
<span className="text-gray-500 ml-2"></span>
</div>
<Button onClick={testLLMConnection} disabled={testingLLM || (!isConfigured && config.llmProvider !== 'ollama')}
className="retro-btn bg-black text-white border-2 border-black hover:bg-gray-800 rounded-none h-10 font-bold uppercase">
{testingLLM ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" /> ...</> : <><PlayCircle className="w-4 h-4 mr-2" /> </>}
</Button>
</div>
{llmTestResult && (
<div className={`p-3 border-2 ${llmTestResult.success ? 'border-green-500 bg-green-50' : 'border-red-500 bg-red-50'}`}>
<div className="flex items-center gap-2 font-mono text-sm">
{llmTestResult.success ? <CheckCircle2 className="h-4 w-4 text-green-600" /> : <AlertCircle className="h-4 w-4 text-red-600" />}
<span className={llmTestResult.success ? 'text-green-800' : 'text-red-800'}>{llmTestResult.message}</span>
</div>
</div>
)}
{/* 高级参数 - 折叠 */}
<details className="pt-4 border-t-2 border-black border-dashed">
<summary className="font-mono font-bold uppercase cursor-pointer hover:text-blue-600"></summary>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div className="space-y-2">
<Label className="font-mono text-xs uppercase"> ()</Label>
<Input type="number" value={config.llmTimeout} onChange={(e) => updateConfig('llmTimeout', Number(e.target.value))}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
</div>
<div className="space-y-2">
<Label className="font-mono text-xs uppercase"> (0-2)</Label>
<Input type="number" step="0.1" min="0" max="2" value={config.llmTemperature}
onChange={(e) => updateConfig('llmTemperature', Number(e.target.value))}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
</div>
<div className="space-y-2">
<Label className="font-mono text-xs uppercase"> Tokens</Label>
<Input type="number" value={config.llmMaxTokens} onChange={(e) => updateConfig('llmMaxTokens', Number(e.target.value))}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
</div>
</div>
</details>
</div>
{/* 使用说明 */}
<div className="bg-gray-50 border-2 border-black p-4 font-mono text-xs space-y-2">
<p className="font-bold uppercase">💡 </p>
<p> <strong>LiteLLM </strong>: LiteLLM </p>
<p> <strong></strong>: MiniMax API 使</p>
<p> <strong>API </strong>: Base URL API Key Key</p>
</div>
</TabsContent>
{/* 分析参数 */}
<TabsContent value="analysis" className="space-y-6">
<div className="retro-card bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label className="font-mono font-bold uppercase"></Label>
<Input type="number" value={config.maxAnalyzeFiles}
onChange={(e) => updateConfig('maxAnalyzeFiles', Number(e.target.value))}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
<p className="text-xs text-gray-500 font-mono"></p>
</div>
<div className="space-y-2">
<Label className="font-mono font-bold uppercase">LLM </Label>
<Input type="number" value={config.llmConcurrency}
onChange={(e) => updateConfig('llmConcurrency', Number(e.target.value))}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
<p className="text-xs text-gray-500 font-mono"> LLM </p>
</div>
<div className="space-y-2">
<Label className="font-mono font-bold uppercase"> ()</Label>
<Input type="number" value={config.llmGapMs}
onChange={(e) => updateConfig('llmGapMs', Number(e.target.value))}
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono" />
<p className="text-xs text-gray-500 font-mono"></p>
</div>
<div className="space-y-2">
<Label className="font-mono font-bold uppercase"></Label>
<Select value={config.outputLanguage} onValueChange={(v) => updateConfig('outputLanguage', v)}>
<SelectTrigger className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-2 border-black rounded-none">
<SelectItem value="zh-CN" className="font-mono">🇨🇳 </SelectItem>
<SelectItem value="en-US" className="font-mono">🇺🇸 English</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500 font-mono"></p>
</div>
</div>
</div>
</TabsContent>
{/* Git 集成 */}
<TabsContent value="git" className="space-y-6">
<div className="retro-card bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] p-6 space-y-6">
<div className="space-y-2">
<Label className="font-mono font-bold uppercase">GitHub Token ()</Label>
<Input
type="password"
value={config.githubToken}
onChange={(e) => updateConfig('githubToken', e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono"
/>
<p className="text-xs text-gray-500 font-mono">
访: <a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">github.com/settings/tokens</a>
</p>
</div>
<div className="space-y-2">
<Label className="font-mono font-bold uppercase">GitLab Token ()</Label>
<Input
type="password"
value={config.gitlabToken}
onChange={(e) => updateConfig('gitlabToken', e.target.value)}
placeholder="glpat-xxxxxxxxxxxx"
className="h-10 bg-gray-50 border-2 border-black rounded-none font-mono"
/>
<p className="text-xs text-gray-500 font-mono">
访: <a href="https://gitlab.com/-/profile/personal_access_tokens" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">gitlab.com/-/profile/personal_access_tokens</a>
</p>
</div>
<div className="bg-gray-50 border-2 border-black p-4 font-mono text-xs">
<p className="font-bold">💡 </p>
<p> Token</p>
<p> Token</p>
</div>
</div>
</TabsContent>
</Tabs>
{/* 底部保存按钮 */}
{hasChanges && (
<div className="fixed bottom-6 right-6 bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] p-4 z-50">
<Button onClick={saveConfig} className="retro-btn bg-black text-white border-2 border-black hover:bg-gray-800 rounded-none h-12 font-bold uppercase">
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</div>
);
}