2025-12-13 12:35:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* System Config Component
|
|
|
|
|
|
* Cyberpunk Terminal Aesthetic
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-10-26 13:38:17 +08:00
|
|
|
|
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 {
|
2025-11-28 16:41:39 +08:00
|
|
|
|
Settings, Save, RotateCcw, Eye, EyeOff, CheckCircle2, AlertCircle,
|
2025-12-13 12:35:03 +08:00
|
|
|
|
Info, Zap, Globe, PlayCircle, Brain
|
2025-10-26 13:38:17 +08:00
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
import { toast } from "sonner";
|
2025-11-26 21:11:12 +08:00
|
|
|
|
import { api } from "@/shared/api/database";
|
2025-12-11 19:09:10 +08:00
|
|
|
|
import EmbeddingConfig from "@/components/agent/EmbeddingConfig";
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
// LLM Providers - 2025
|
2025-10-26 13:38:17 +08:00
|
|
|
|
const LLM_PROVIDERS = [
|
feat(llm): enhance LLM connection testing with improved error handling and adapter instantiation
- Bypass LLMFactory cache during connection tests to ensure fresh API calls with latest configuration
- Directly instantiate native adapters (Baidu, Minimax, Doubao) and LiteLLM adapter based on provider type
- Add comprehensive error handling in LiteLLM adapter with specific exception catching for authentication, rate limiting, and connection errors
- Implement user-friendly error messages for common failure scenarios (invalid API key, authentication failure, timeout, connection issues)
- Add response validation to detect and report empty API responses
- Disable LiteLLM internal caching to guarantee actual API calls during testing
- Update available models list with 2025 latest models across all providers (Gemini, OpenAI, Claude, Qwen, DeepSeek, etc.)
- Improve error message clarity and debugging information in config endpoint
2025-11-28 16:53:01 +08:00
|
|
|
|
{ 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 等' },
|
2025-10-26 13:38:17 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
2025-11-28 16:41:39 +08:00
|
|
|
|
const DEFAULT_MODELS: Record<string, string> = {
|
feat(llm): enhance LLM connection testing with improved error handling and adapter instantiation
- Bypass LLMFactory cache during connection tests to ensure fresh API calls with latest configuration
- Directly instantiate native adapters (Baidu, Minimax, Doubao) and LiteLLM adapter based on provider type
- Add comprehensive error handling in LiteLLM adapter with specific exception catching for authentication, rate limiting, and connection errors
- Implement user-friendly error messages for common failure scenarios (invalid API key, authentication failure, timeout, connection issues)
- Add response validation to detect and report empty API responses
- Disable LiteLLM internal caching to guarantee actual API calls during testing
- Update available models list with 2025 latest models across all providers (Gemini, OpenAI, Claude, Qwen, DeepSeek, etc.)
- Improve error message clarity and debugging information in config endpoint
2025-11-28 16:53:01 +08:00
|
|
|
|
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',
|
2025-10-26 13:38:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
interface SystemConfigData {
|
2025-11-28 16:41:39 +08:00
|
|
|
|
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;
|
2025-10-26 13:38:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function SystemConfig() {
|
2025-11-26 21:11:12 +08:00
|
|
|
|
const [config, setConfig] = useState<SystemConfigData | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
2025-11-28 16:41:39 +08:00
|
|
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
const [hasChanges, setHasChanges] = useState(false);
|
2025-11-28 16:41:39 +08:00
|
|
|
|
const [testingLLM, setTestingLLM] = useState(false);
|
|
|
|
|
|
const [llmTestResult, setLlmTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-11-28 16:41:39 +08:00
|
|
|
|
useEffect(() => { loadConfig(); }, []);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-11-26 21:11:12 +08:00
|
|
|
|
const loadConfig = async () => {
|
2025-10-26 13:38:17 +08:00
|
|
|
|
try {
|
2025-11-26 21:11:12 +08:00
|
|
|
|
setLoading(true);
|
2025-11-28 17:51:17 +08:00
|
|
|
|
console.log('[SystemConfig] 开始加载配置...');
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-26 21:11:12 +08:00
|
|
|
|
const backendConfig = await api.getUserConfig();
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-28 17:51:17 +08:00
|
|
|
|
console.log('[SystemConfig] 后端返回的原始数据:', JSON.stringify(backendConfig, null, 2));
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-28 17:51:17 +08:00
|
|
|
|
if (backendConfig) {
|
|
|
|
|
|
const llmConfig = backendConfig.llmConfig || {};
|
|
|
|
|
|
const otherConfig = backendConfig.otherConfig || {};
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-28 17:51:17 +08:00
|
|
|
|
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 || '',
|
2025-12-16 13:04:09 +08:00
|
|
|
|
maxAnalyzeFiles: otherConfig.maxAnalyzeFiles ?? 0,
|
2025-11-28 17:51:17 +08:00
|
|
|
|
llmConcurrency: otherConfig.llmConcurrency || 3,
|
|
|
|
|
|
llmGapMs: otherConfig.llmGapMs || 2000,
|
|
|
|
|
|
outputLanguage: otherConfig.outputLanguage || 'zh-CN',
|
|
|
|
|
|
};
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-28 17:51:17 +08:00
|
|
|
|
console.log('[SystemConfig] 解析后的配置:', newConfig);
|
|
|
|
|
|
setConfig(newConfig);
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-28 17:51:17 +08:00
|
|
|
|
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: '',
|
2025-12-16 13:04:09 +08:00
|
|
|
|
maxAnalyzeFiles: 0, llmConcurrency: 3, llmGapMs: 2000, outputLanguage: 'zh-CN',
|
2025-11-28 17:51:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load config:', error);
|
2025-11-28 16:41:39 +08:00
|
|
|
|
setConfig({
|
|
|
|
|
|
llmProvider: 'openai', llmApiKey: '', llmModel: '', llmBaseUrl: '',
|
|
|
|
|
|
llmTimeout: 150000, llmTemperature: 0.1, llmMaxTokens: 4096,
|
|
|
|
|
|
githubToken: '', gitlabToken: '',
|
2025-12-16 13:04:09 +08:00
|
|
|
|
maxAnalyzeFiles: 0, llmConcurrency: 3, llmGapMs: 2000, outputLanguage: 'zh-CN',
|
2025-11-28 16:41:39 +08:00
|
|
|
|
});
|
2025-11-26 21:11:12 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-26 21:11:12 +08:00
|
|
|
|
const saveConfig = async () => {
|
2025-11-28 16:41:39 +08:00
|
|
|
|
if (!config) return;
|
2025-10-26 13:38:17 +08:00
|
|
|
|
try {
|
2025-11-28 17:51:17 +08:00
|
|
|
|
const savedConfig = await api.updateUserConfig({
|
2025-11-28 16:41:39 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
2025-11-26 21:11:12 +08:00
|
|
|
|
});
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-11-28 17:51:17 +08:00
|
|
|
|
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 || '',
|
2025-12-16 13:04:09 +08:00
|
|
|
|
maxAnalyzeFiles: otherConfig.maxAnalyzeFiles ?? 0,
|
2025-11-28 17:51:17 +08:00
|
|
|
|
llmConcurrency: otherConfig.llmConcurrency || 3,
|
|
|
|
|
|
llmGapMs: otherConfig.llmGapMs || 2000,
|
|
|
|
|
|
outputLanguage: otherConfig.outputLanguage || 'zh-CN',
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
|
2025-10-26 13:38:17 +08:00
|
|
|
|
setHasChanges(false);
|
2025-11-28 16:41:39 +08:00
|
|
|
|
toast.success("配置已保存!");
|
2025-10-26 13:38:17 +08:00
|
|
|
|
} catch (error) {
|
2025-11-28 16:41:39 +08:00
|
|
|
|
toast.error(`保存失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-26 21:11:12 +08:00
|
|
|
|
const resetConfig = async () => {
|
2025-11-28 16:41:39 +08:00
|
|
|
|
if (!window.confirm("确定要重置为默认配置吗?")) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteUserConfig();
|
|
|
|
|
|
await loadConfig();
|
|
|
|
|
|
setHasChanges(false);
|
|
|
|
|
|
toast.success("已重置为默认配置");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast.error(`重置失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-28 16:41:39 +08:00
|
|
|
|
const updateConfig = (key: keyof SystemConfigData, value: string | number) => {
|
2025-11-26 21:11:12 +08:00
|
|
|
|
if (!config) return;
|
|
|
|
|
|
setConfig(prev => prev ? { ...prev, [key]: value } : null);
|
2025-10-26 13:38:17 +08:00
|
|
|
|
setHasChanges(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-28 16:41:39 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-26 21:11:12 +08:00
|
|
|
|
if (loading || !config) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="text-center space-y-4">
|
|
|
|
|
|
<div className="loading-spinner mx-auto" />
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-muted-foreground font-mono text-sm uppercase tracking-wider">加载配置中...</p>
|
2025-11-26 21:11:12 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-11-28 16:41:39 +08:00
|
|
|
|
const currentProvider = LLM_PROVIDERS.find(p => p.value === config.llmProvider);
|
|
|
|
|
|
const isConfigured = config.llmApiKey !== '' || config.llmProvider === 'ollama';
|
|
|
|
|
|
|
2025-10-26 13:38:17 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-6">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Status Bar */}
|
|
|
|
|
|
<div className={`cyber-card p-4 ${isConfigured ? 'border-emerald-500/30' : 'border-amber-500/30'}`}>
|
|
|
|
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<Info className="h-5 w-5 text-sky-400" />
|
|
|
|
|
|
<span className="font-mono text-sm">
|
|
|
|
|
|
{isConfigured ? (
|
|
|
|
|
|
<span className="text-emerald-400 flex items-center gap-2">
|
|
|
|
|
|
<CheckCircle2 className="h-4 w-4" /> LLM 已配置 ({currentProvider?.label})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-amber-400 flex items-center gap-2">
|
|
|
|
|
|
<AlertCircle className="h-4 w-4" /> 请配置 LLM API Key
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
{hasChanges && (
|
|
|
|
|
|
<Button onClick={saveConfig} size="sm" className="cyber-btn-primary h-8">
|
|
|
|
|
|
<Save className="w-3 h-3 mr-2" /> 保存
|
|
|
|
|
|
</Button>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
)}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button onClick={resetConfig} variant="outline" size="sm" className="cyber-btn-ghost h-8">
|
|
|
|
|
|
<RotateCcw className="w-3 h-3 mr-2" /> 重置
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</Button>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
</div>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
|
|
|
|
|
<Tabs defaultValue="llm" className="w-full">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<TabsList className="grid w-full grid-cols-4 bg-muted border border-border p-1 h-auto gap-1 rounded-lg mb-6">
|
|
|
|
|
|
<TabsTrigger value="llm" className="data-[state=active]:bg-primary data-[state=active]:text-foreground font-mono font-bold uppercase py-2.5 text-muted-foreground transition-all rounded text-xs flex items-center gap-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Zap className="w-3 h-3" /> LLM 配置
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</TabsTrigger>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<TabsTrigger value="embedding" className="data-[state=active]:bg-primary data-[state=active]:text-foreground font-mono font-bold uppercase py-2.5 text-muted-foreground transition-all rounded text-xs flex items-center gap-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Brain className="w-3 h-3" /> 嵌入模型
|
2025-12-11 19:09:10 +08:00
|
|
|
|
</TabsTrigger>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<TabsTrigger value="analysis" className="data-[state=active]:bg-primary data-[state=active]:text-foreground font-mono font-bold uppercase py-2.5 text-muted-foreground transition-all rounded text-xs flex items-center gap-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Settings className="w-3 h-3" /> 分析参数
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</TabsTrigger>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<TabsTrigger value="git" className="data-[state=active]:bg-primary data-[state=active]:text-foreground font-mono font-bold uppercase py-2.5 text-muted-foreground transition-all rounded text-xs flex items-center gap-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Globe className="w-3 h-3" /> Git 集成
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</TabsTrigger>
|
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* LLM Config */}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<TabsContent value="llm" className="space-y-6">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="cyber-card p-6 space-y-6">
|
|
|
|
|
|
{/* Provider Selection */}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">选择 LLM 提供商</Label>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<Select value={config.llmProvider} onValueChange={(v) => updateConfig('llmProvider', v)}>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<SelectTrigger className="h-12 cyber-input">
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<SelectContent className="cyber-dialog border-border">
|
|
|
|
|
|
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground uppercase">LiteLLM 统一适配 (推荐)</div>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
{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>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<span className="text-xs text-muted-foreground">- {p.hint}</span>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="px-2 py-1.5 text-xs font-bold text-muted-foreground uppercase mt-2">原生适配器</div>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
{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>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<span className="text-xs text-muted-foreground">- {p.hint}</span>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-11-28 16:41:39 +08:00
|
|
|
|
{/* API Key */}
|
|
|
|
|
|
{config.llmProvider !== 'ollama' && (
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">API Key</Label>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Input
|
2025-11-28 16:41:39 +08:00
|
|
|
|
type={showApiKey ? 'text' : 'password'}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
value={config.llmApiKey}
|
|
|
|
|
|
onChange={(e) => updateConfig('llmApiKey', e.target.value)}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
placeholder={config.llmProvider === 'baidu' ? 'API_KEY:SECRET_KEY 格式' : '输入你的 API Key'}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-12 cyber-input"
|
2025-10-26 13:38:17 +08:00
|
|
|
|
/>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
|
|
|
|
className="h-12 w-12 cyber-btn-ghost"
|
|
|
|
|
|
>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
)}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Model and Base URL */}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">模型名称 (可选)</Label>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
value={config.llmModel}
|
|
|
|
|
|
onChange={(e) => updateConfig('llmModel', e.target.value)}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
placeholder={`默认: ${DEFAULT_MODELS[config.llmProvider] || 'auto'}`}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
2025-10-26 13:38:17 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">API Base URL (可选)</Label>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
value={config.llmBaseUrl}
|
|
|
|
|
|
onChange={(e) => updateConfig('llmBaseUrl', e.target.value)}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
placeholder="留空使用官方地址,或填入中转站地址"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
2025-10-26 13:38:17 +08:00
|
|
|
|
/>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Test Connection */}
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="pt-4 border-t border-border border-dashed flex items-center justify-between flex-wrap gap-4">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="text-sm">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<span className="font-bold text-foreground">测试连接</span>
|
|
|
|
|
|
<span className="text-muted-foreground ml-2">验证配置是否正确</span>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</div>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
onClick={testLLMConnection}
|
|
|
|
|
|
disabled={testingLLM || (!isConfigured && config.llmProvider !== 'ollama')}
|
|
|
|
|
|
className="cyber-btn-primary h-10"
|
|
|
|
|
|
>
|
|
|
|
|
|
{testingLLM ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
|
|
|
|
|
测试中...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PlayCircle className="w-4 h-4 mr-2" />
|
|
|
|
|
|
测试
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{llmTestResult && (
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className={`p-3 rounded-lg ${llmTestResult.success ? 'bg-emerald-500/10 border border-emerald-500/30' : 'bg-rose-500/10 border border-rose-500/30'}`}>
|
|
|
|
|
|
<div className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
{llmTestResult.success ? (
|
|
|
|
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<AlertCircle className="h-4 w-4 text-rose-400" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className={llmTestResult.success ? 'text-emerald-300/80' : 'text-rose-300/80'}>
|
|
|
|
|
|
{llmTestResult.message}
|
|
|
|
|
|
</span>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
)}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Advanced Parameters */}
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<details className="pt-4 border-t border-border border-dashed">
|
|
|
|
|
|
<summary className="font-bold uppercase cursor-pointer hover:text-primary text-muted-foreground text-sm">高级参数</summary>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs text-muted-foreground uppercase">超时 (毫秒)</Label>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={config.llmTimeout}
|
|
|
|
|
|
onChange={(e) => updateConfig('llmTimeout', Number(e.target.value))}
|
|
|
|
|
|
className="h-10 cyber-input"
|
|
|
|
|
|
/>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs text-muted-foreground uppercase">温度 (0-2)</Label>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
step="0.1"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="2"
|
|
|
|
|
|
value={config.llmTemperature}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
onChange={(e) => updateConfig('llmTemperature', Number(e.target.value))}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
|
|
|
|
|
/>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs text-muted-foreground uppercase">最大 Tokens</Label>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={config.llmMaxTokens}
|
|
|
|
|
|
onChange={(e) => updateConfig('llmMaxTokens', Number(e.target.value))}
|
|
|
|
|
|
className="h-10 cyber-input"
|
|
|
|
|
|
/>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</details>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Usage Notes */}
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="bg-muted border border-border p-4 rounded-lg text-xs space-y-2">
|
|
|
|
|
|
<p className="font-bold uppercase text-muted-foreground flex items-center gap-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Info className="w-4 h-4 text-sky-400" />
|
|
|
|
|
|
配置说明
|
|
|
|
|
|
</p>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-muted-foreground">• <strong className="text-muted-foreground">LiteLLM 统一适配</strong>: 大多数提供商通过 LiteLLM 统一处理,支持自动重试和负载均衡</p>
|
|
|
|
|
|
<p className="text-muted-foreground">• <strong className="text-muted-foreground">原生适配器</strong>: 百度、MiniMax、豆包因 API 格式特殊,使用专用适配器</p>
|
|
|
|
|
|
<p className="text-muted-foreground">• <strong className="text-muted-foreground">API 中转站</strong>: 在 Base URL 填入中转站地址即可,API Key 填中转站提供的 Key</p>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Embedding Config */}
|
2025-12-11 19:09:10 +08:00
|
|
|
|
<TabsContent value="embedding" className="space-y-6">
|
|
|
|
|
|
<EmbeddingConfig />
|
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Analysis Parameters */}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<TabsContent value="analysis" className="space-y-6">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="cyber-card p-6 space-y-6">
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">最大分析文件数</Label>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={config.maxAnalyzeFiles}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
onChange={(e) => updateConfig('maxAnalyzeFiles', Number(e.target.value))}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
|
|
|
|
|
/>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">单次任务最多处理的文件数量</p>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">LLM 并发数</Label>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={config.llmConcurrency}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
onChange={(e) => updateConfig('llmConcurrency', Number(e.target.value))}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
|
|
|
|
|
/>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">同时发送的 LLM 请求数量</p>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">请求间隔 (毫秒)</Label>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={config.llmGapMs}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
onChange={(e) => updateConfig('llmGapMs', Number(e.target.value))}
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
|
|
|
|
|
/>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">每个请求之间的延迟时间</p>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">输出语言</Label>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<Select value={config.outputLanguage} onValueChange={(v) => updateConfig('outputLanguage', v)}>
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<SelectTrigger className="h-10 cyber-input">
|
2025-10-26 13:38:17 +08:00
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<SelectContent className="cyber-dialog border-border">
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<SelectItem value="zh-CN" className="font-mono">🇨🇳 中文</SelectItem>
|
|
|
|
|
|
<SelectItem value="en-US" className="font-mono">🇺🇸 English</SelectItem>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">代码审查结果的输出语言</p>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</div>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Git Integration */}
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<TabsContent value="git" className="space-y-6">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="cyber-card p-6 space-y-6">
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">GitHub Token (可选)</Label>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
value={config.githubToken}
|
|
|
|
|
|
onChange={(e) => updateConfig('githubToken', e.target.value)}
|
|
|
|
|
|
placeholder="ghp_xxxxxxxxxxxx"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
2025-11-28 16:41:39 +08:00
|
|
|
|
/>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
用于访问私有仓库。获取:{' '}
|
|
|
|
|
|
<a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
|
|
|
|
github.com/settings/tokens
|
|
|
|
|
|
</a>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</p>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<div className="space-y-2">
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<Label className="text-xs font-bold text-muted-foreground uppercase">GitLab Token (可选)</Label>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="password"
|
|
|
|
|
|
value={config.gitlabToken}
|
|
|
|
|
|
onChange={(e) => updateConfig('gitlabToken', e.target.value)}
|
|
|
|
|
|
placeholder="glpat-xxxxxxxxxxxx"
|
2025-12-13 12:35:03 +08:00
|
|
|
|
className="h-10 cyber-input"
|
2025-11-28 16:41:39 +08:00
|
|
|
|
/>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
用于访问私有仓库。获取:{' '}
|
|
|
|
|
|
<a href="https://gitlab.com/-/profile/personal_access_tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
|
|
|
|
gitlab.com/-/profile/personal_access_tokens
|
|
|
|
|
|
</a>
|
2025-11-28 16:41:39 +08:00
|
|
|
|
</p>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<div className="bg-muted border border-border p-4 rounded-lg text-xs">
|
|
|
|
|
|
<p className="font-bold text-muted-foreground flex items-center gap-2 mb-2">
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<Info className="w-4 h-4 text-sky-400" />
|
|
|
|
|
|
提示
|
|
|
|
|
|
</p>
|
2025-12-18 19:57:43 +08:00
|
|
|
|
<p className="text-muted-foreground">• 公开仓库无需配置 Token</p>
|
|
|
|
|
|
<p className="text-muted-foreground">• 私有仓库需要配置对应平台的 Token</p>
|
2025-11-27 21:33:51 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
|
2025-12-13 12:35:03 +08:00
|
|
|
|
{/* Floating Save Button */}
|
2025-10-26 13:38:17 +08:00
|
|
|
|
{hasChanges && (
|
2025-12-13 12:35:03 +08:00
|
|
|
|
<div className="fixed bottom-6 right-6 cyber-card p-4 z-50">
|
|
|
|
|
|
<Button onClick={saveConfig} className="cyber-btn-primary h-12">
|
2025-11-28 16:41:39 +08:00
|
|
|
|
<Save className="w-4 h-4 mr-2" /> 保存所有更改
|
2025-10-26 13:38:17 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|