Files
aiops/frontend/src/components/aiops/AIOpsAssistant.tsx
T
attacker c68aef4444 feat(alert): A2 告警引擎分布式安全 — Redis 分布式锁(SetNX)
- Cache 接口新增 SetNX 方法(go-redis 原子 / redka check-then-set 单副本安全)
- AlertEngine 改造为支持分布式锁: tryAcquireLock/releaseLock/GetEngineStatus
- 多副本部署时仅 Leader 执行告警检查, 60s TTL 防死锁, fail-open 容错
- 新增 GET /alerts/engine/status 监控端点(is_leader/instance_id/lock_holder)
- 修复 GrafanaDashboard.tsx Panel.options 类型不匹配导致构建中断
- 同步未提交的前端类型安全迁移(140+ 文件)
- ROADMAP/TDD_PLAN/PROJECT_EVALUATION 三文档同步更新

验证: go build 零错误, Docker 部署 healthy, 引擎状态端点正常,
锁竞争测试通过(非 Leader 跳过检查), 5/5 核心 API 回归通过
2026-06-26 21:13:15 +08:00

696 lines
31 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, useRef, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { aiApi, alertApi, agentApi, dashboardStatsApi, type LLMConfigData, type ChatHistoryItem } from '@/api';
import {
Bot,
Send,
Settings,
AlertTriangle,
Activity,
FileText,
TrendingUp,
Terminal,
RotateCw,
User,
MessageSquare,
Brain,
Loader2,
CheckCircle2,
Wifi,
Square,
} from 'lucide-react';
import type { JsonData } from '@/types/common';
// LLM 提供商列表(OpenAI 兼容接口) // Logan.Li
const LLM_PROVIDERS: { id: string; name: string; defaultEndpoint: string; models: string[] }[] = [
{ id: 'openai', name: 'OpenAI', defaultEndpoint: 'https://api.openai.com/v1', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'] },
{ id: 'deepseek', name: 'DeepSeek', defaultEndpoint: 'https://api.deepseek.com/v1', models: ['deepseek-chat', 'deepseek-reasoner'] },
{ id: 'qwen', name: '通义千问 (DashScope)', defaultEndpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', models: ['qwen-plus', 'qwen-turbo', 'qwen-max'] },
{ id: 'moonshot', name: 'Moonshot 月之暗面', defaultEndpoint: 'https://api.moonshot.cn/v1', models: ['moonshot-v1-8k', 'moonshot-v1-32k'] },
{ id: 'ollama', name: 'Ollama 本地', defaultEndpoint: 'http://localhost:11434/v1', models: ['qwen2.5:7b', 'llama3.2', 'deepseek-r1:7b'] },
{ id: 'custom', name: '自定义 (OpenAI 兼容)', defaultEndpoint: '', models: [] },
];
const defaultConfig: LLMConfigData = {
provider: 'openai',
model: 'gpt-4o-mini',
api_key: '',
endpoint: 'https://api.openai.com/v1',
temperature: 0.7,
max_tokens: 2000,
system_prompt: '',
enabled: false,
};
// ============ 快捷操作平台数据加载器(注入真实运维上下文) // Logan.Li ============
// 拉取最近告警事件,格式化为紧凑列表供 LLM 分析
async function loadAlertsContext(): Promise<string> {
const res = await alertApi.getAlerts({ page: 1, page_size: 20 });
const alerts: JsonData[] = (res as JsonData)?.list || [];
if (!alerts.length) return '(当前无告警事件)';
const lines = alerts.slice(0, 15).map((a, i) =>
`${i + 1}. [${a.severity || '未知'}|${a.status || '-'}] ${a.rule_name || '告警'} → 目标:${a.target || '-'} 值:${a.value ?? '-'} 时间:${(a.started_at || '').slice(0, 16)}`,
);
const firing = alerts.filter((a) => a.status === 'firing').length;
return `共 ${alerts.length} 条告警(firing ${firing} 条),前 ${Math.min(15, alerts.length)} 条:\n${lines.join('\n')}`;
}
// 拉取 Agent 实时主机指标,识别高负载主机
async function loadMetricsContext(): Promise<string> {
const res = await agentApi.getLatest();
const hosts: JsonData[] = Array.isArray(res) ? res : (res as JsonData)?.list || [];
if (!hosts.length) return '(当前无 Agent 上报数据,可能未部署监控 Agent)';
const lines = hosts.slice(0, 20).map((h, i) => {
const cpu = h.cpu_usage ?? '-';
const mem = h.mem_usage ?? '-';
return `${i + 1}. ${h.hostname || '-'} | CPU:${cpu}% | 内存:${mem}% (${h.mem_used ?? '-'}/${h.mem_total ?? '-'}GB) | 核数:${h.cpu_cores ?? '-'} | 上报:${(h.report_time || '').slice(0, 16)}`;
});
const hot = hosts.filter((h) => (h.cpu_usage ?? 0) >= 80 || (h.mem_usage ?? 0) >= 80);
const note = hot.length
? `\n⚠️ 高负载主机 ${hot.length} 台:${hot.map((h) => `${h.hostname}(CPU${h.cpu_usage}%/MEM${h.mem_usage}%)`).join('、')}`
: '';
return `Agent 监控的 ${hosts.length} 台主机实时指标:\n${lines.join('\n')}${note}`;
}
// 拉取仪表盘综合数据,生成运维报告上下文
async function loadReportContext(): Promise<string> {
const [stats, alerts, releases] = await Promise.all([
dashboardStatsApi.getStats().catch(() => null),
dashboardStatsApi.getAlerts(10).catch(() => null),
dashboardStatsApi.getReleases(10).catch(() => null),
]);
const s: JsonData = stats || {};
const ss = s.server_stats || {}, ks = s.kubernetes_stats || {}, as = s.alert_stats || {}, ps = s.pipeline_stats || {}, cs = s.cost_stats || {};
const overview = `服务器:${ss.total ?? 0}(运行${ss.running ?? 0}) | Kubernetes集群:${ks.total ?? 0} 节点:${ks.total_nodes ?? 0} Pod:${ks.total_pods ?? 0} | 告警:${as.total ?? 0}(firing ${as.firing ?? 0}) | 流水线:${ps.total ?? 0}(成功率${ps.success_rate ?? 0}%) | 本月成本:¥${cs.month_cost ?? 0}`;
const alertList: JsonData[] = Array.isArray(alerts) ? alerts : (alerts as JsonData)?.list || [];
const releaseList: JsonData[] = Array.isArray(releases) ? releases : (releases as JsonData)?.list || [];
const alertLines = alertList.slice(0, 10).map((a, i) => `${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`);
const releaseLines = releaseList.slice(0, 10).map((r, i) => `${i + 1}.${r.app_name || '-'} ${r.version || ''} [${r.environment || '-'}/${r.status || '-'}] by ${r.author || '-'}`);
return `平台概览:${overview}\n\n最近告警:\n${alertLines.join('\n') || '(无)'}\n\n最近发布:\n${releaseLines.join('\n') || '(无)'}`;
}
// 快捷操作类型(contextLoader 可选,支持未来无数据加载的纯文本操作) // Logan.Li
interface QuickAction {
id: string;
name: string;
icon: typeof AlertTriangle;
prompt: string;
contextLoader?: () => Promise<string>;
}
// 快捷操作(已配置 LLM 时自动拉取真实平台数据注入上下文后发送) // Logan.Li
const quickActions: QuickAction[] = [
{ id: 'alerts', name: '分析今日告警', icon: AlertTriangle, prompt: '帮我分析以下最近的告警事件,给出根因分析和处置建议(按严重程度排序,给出具体处置步骤)', contextLoader: loadAlertsContext },
{ id: 'performance', name: '性能瓶颈分析', icon: Activity, prompt: '分析以下主机实时指标,找出 CPU/内存使用率最高的主机并给出性能优化建议', contextLoader: loadMetricsContext },
{ id: 'report', name: '生成运维报告', icon: FileText, prompt: '基于以下平台实时数据,生成一份简洁的今日运维报告,包括资源概览、告警情况和发布状态,用 Markdown 格式输出', contextLoader: loadReportContext },
{ id: 'optimize', name: '资源优化建议', icon: TrendingUp, prompt: '根据以下主机资源使用数据,提供成本优化和容量规划建议(识别闲置/过载主机,给出缩容/扩容建议)', contextLoader: loadMetricsContext },
];
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
isError?: boolean;
isStopped?: boolean;
}
// 格式化时间为 HH:MM // Logan.Li
function fmtTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
export function AIOpsAssistant() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
// 用于中止流式生成(点击"停止"按钮时 abort) // Logan.Li
const abortRef = useRef<AbortController | null>(null);
// 组件卸载时中止进行中的流式请求,避免 setState on unmounted // Logan.Li
useEffect(() => () => abortRef.current?.abort(), []);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<LLMConfigData>(defaultConfig);
const [configReady, setConfigReady] = useState(false); // 已配置且启用
const [contextLoading, setContextLoading] = useState<string | null>(null); // 快捷操作拉取平台数据中
// 会话 ID:前端生成 UUID,用于隔离多轮对话 // Logan.Li
// crypto.randomUUID() 需要 HTTPS/secure context,HTTP 环境用 fallback
const generateUUID = (): string => {
try { return crypto.randomUUID(); } catch { /* HTTP fallback below */ }
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
const [sessionId, setSessionId] = useState(() => generateUUID());
const messagesEndRef = useRef<HTMLDivElement>(null);
// 滚动到底部 // Logan.Li
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
useEffect(() => { scrollToBottom(); }, [messages]);
// 加载 LLM 配置 // Logan.Li
const loadConfig = async () => {
try {
const cfg = await aiApi.getConfig();
setConfig(cfg);
setConfigReady(!!(cfg.enabled && cfg.configured));
} catch {
setConfigReady(false);
}
};
// 加载历史对话(按 session 隔离) // Logan.Li
const loadHistory = async () => {
try {
const res = await aiApi.getHistory(sessionId);
const items: ChatHistoryItem[] = (res as JsonData)?.list || [];
setMessages(
items.map((m) => ({
id: String(m.id),
role: m.role,
content: m.content,
timestamp: m.created_at,
})),
);
} catch {
// 历史加载失败不阻塞主流程
}
};
useEffect(() => {
loadConfig();
loadHistory();
}, []);
// 切换提供商时自动填充默认 endpoint + 模型 // Logan.Li
const handleProviderChange = (provider: string) => {
const p = LLM_PROVIDERS.find((x) => x.id === provider);
setConfig((prev) => ({
...prev,
provider,
endpoint: p?.defaultEndpoint || prev.endpoint,
model: p?.models[0] || prev.model,
}));
};
// 保存配置 // Logan.Li
const handleSaveConfig = async () => {
if (config.enabled && !config.api_key && config.provider !== 'ollama') {
toast.error('请填写 API Key');
return;
}
setSaving(true);
try {
const saved = await aiApi.saveConfig(config);
setConfig(saved);
setConfigReady(!!(saved.enabled && (saved as JsonData).configured));
toast.success('配置已保存');
setIsConfigOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
// 测试连接 // Logan.Li
const handleTest = async () => {
setTesting(true);
try {
await aiApi.testConnection();
toast.success('LLM 服务连接成功');
} catch (e) {
toast.error(e instanceof Error ? e.message : '连接失败');
} finally {
setTesting(false);
}
};
// 发送一条消息的核心逻辑(流式输出,逐步填充助手气泡) // Logan.Li
const sendMessage = async (text: string) => {
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: text,
timestamp: new Date().toISOString(),
};
const assistantId = (Date.now() + 1).toString();
const newMessages: Message[] = [
...messages,
userMsg,
// 预先插入空助手消息,随流式内容逐步填充
{ id: assistantId, role: 'assistant', content: '', timestamp: new Date().toISOString() },
];
setMessages(newMessages);
setIsLoading(true);
// 创建 AbortController 以支持\"停止生成\" // Logan.Li
const controller = new AbortController();
abortRef.current = controller;
// 用于在收到第一个 chunk 前显示"思考中"占位
let firstChunkArrived = false;
try {
// 仅保留最近 10 条上下文避免 token 超限(不含刚插入的空助手消息) // Logan.Li
const history = [...messages, userMsg]
.slice(-10)
.map((m) => ({ role: m.role, content: m.content }));
await aiApi.chatStream(history, (delta) => {
firstChunkArrived = true;
// 逐块追加到助手消息内容
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content + delta } : m,
),
);
}, controller.signal, sessionId);
} catch (e) {
// 用户主动中止:保留已生成的部分内容,不当作错误 // Logan.Li
if (controller.signal.aborted) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: m.content || '(已停止生成)',
isStopped: true,
}
: m,
),
);
} else {
const errMsg = e instanceof Error ? e.message : '对话失败';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: firstChunkArrived ? m.content + `\n\n⚠️ ${errMsg}` : `⚠️ ${errMsg}`,
isError: !firstChunkArrived,
}
: m,
),
);
}
} finally {
setIsLoading(false);
abortRef.current = null;
}
};
// 输入框发送 // Logan.Li
const handleSend = async () => {
const text = input.trim();
if (!text || isLoading) return;
setInput('');
await sendMessage(text);
};
// 快捷操作:已配置 LLM 时拉取真实平台数据注入上下文后自动发送;未配置则仅填入输入框 // Logan.Li
const handleQuickAction = async (action: QuickAction) => {
if (isLoading || contextLoading) return;
// 未配置或无数据加载器时,回退到填入输入框
if (!configReady || !action.contextLoader) {
setInput(action.prompt);
return;
}
// 拉取真实平台数据,失败时注入错误说明而非中断
setContextLoading(action.id);
let context = '';
try {
context = await action.contextLoader();
} catch {
context = '(平台数据加载失败,请稍后重试或检查后端服务)';
} finally {
setContextLoading(null);
}
const fullPrompt = `${action.prompt}\n\n【平台实时数据】\n${context}`;
await sendMessage(fullPrompt);
};
// 新对话(生成新 session_id,清空本地 + 后端持久化历史) // Logan.Li
const handleNewChat = async () => {
try {
await aiApi.clearHistory();
} catch {
// 清除失败不阻塞本地清空
}
const newSid = generateUUID();
setSessionId(newSid);
setMessages([]);
setInput('');
toast.success('已开始新对话');
};
const currentProvider = LLM_PROVIDERS.find((p) => p.id === config.provider);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AIOps 智能助手</h1>
<p className="text-sm text-slate-500">AI 驱动的智能运维助手</p>
</div>
</div>
<div className="flex items-center gap-3">
{configReady ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle2 className="h-3 w-3 mr-1" />
{config.model}
</Badge>
) : (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
未配置
</Badge>
)}
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsConfigOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
模型配置
</Button>
</div>
</div>
<div className="grid grid-cols-4 gap-6 h-[calc(100vh-200px)]">
{/* Sidebar */}
<div className="col-span-1 space-y-4">
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-300">快速操作</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{quickActions.map((action) => (
<Button
key={action.id}
variant="ghost"
className="w-full justify-start text-slate-300 hover:text-slate-100 hover:bg-slate-700"
disabled={isLoading || contextLoading !== null}
onClick={() => handleQuickAction(action)}
>
{contextLoading === action.id ? (
<Loader2 className="h-4 w-4 mr-2 shrink-0 animate-spin" />
) : (
<action.icon className="h-4 w-4 mr-2 shrink-0" />
)}
<span className="text-sm">{action.name}</span>
{configReady && action.contextLoader && (
<span className="ml-auto text-[10px] text-emerald-400/60 shrink-0">实时</span>
)}
</Button>
))}
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-300">能力展示</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm text-slate-400">
<AlertTriangle className="h-4 w-4 text-amber-400" />
<span>智能告警分析</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<Activity className="h-4 w-4 text-blue-400" />
<span>性能瓶颈识别</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<FileText className="h-4 w-4 text-emerald-400" />
<span>自动报告生成</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<Terminal className="h-4 w-4 text-purple-400" />
<span>脚本自动生成</span>
</div>
</CardContent>
</Card>
</div>
{/* Chat Area */}
<Card className="col-span-3 bg-slate-800 border-slate-700 flex flex-col">
<CardHeader className="border-b border-slate-700 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-purple-400" />
<span className="font-medium text-slate-100">AI 助手</span>
{configReady ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-xs">
<span className="w-2 h-2 rounded-full bg-emerald-400 mr-1 animate-pulse" />
在线
</Badge>
) : (
<Badge variant="outline" className="border-slate-600 text-slate-400 text-xs">离线</Badge>
)}
</div>
<Button variant="ghost" size="sm" className="text-slate-400" onClick={handleNewChat} disabled={messages.length === 0}>
<RotateCw className="h-4 w-4 mr-1" />
新对话
</Button>
</div>
</CardHeader>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
<MessageSquare className="h-10 w-10 mb-3 opacity-30" />
<p>{configReady ? '暂无对话记录,输入问题开始与 AI 助手对话' : 'AI 助手尚未配置大模型'}</p>
{!configReady && (
<Button variant="outline" size="sm" className="mt-4 border-slate-600 text-slate-300" onClick={() => setIsConfigOpen(true)}>
<Settings className="h-4 w-4 mr-1" />
前往配置
</Button>
)}
</div>
)}
{messages.map((message) => {
// 流式中:助手消息内容为空且正在加载时显示"思考中"占位 // Logan.Li
const isStreaming = isLoading && message.role === 'assistant' && message.content === '';
return (
<div key={message.id} className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}>
<Avatar className={message.role === 'assistant' ? 'bg-purple-500' : 'bg-blue-500'}>
<AvatarFallback>
{message.role === 'assistant' ? <Bot className="h-4 w-4" /> : <User className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className={`max-w-[80%] ${message.role === 'user' ? 'text-right' : ''}`}>
<div
className={`inline-block rounded-lg p-3 text-left whitespace-pre-wrap break-words ${
message.isError
? 'bg-red-900/30 border border-red-500/30 text-red-300'
: message.role === 'assistant'
? 'bg-slate-700 text-slate-100'
: 'bg-blue-600 text-white'
}`}
>
{isStreaming ? (
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">思考中...</span>
</div>
) : (
message.content
)}
{/* 流式接收中且已有内容时,显示末尾闪烁光标提示 */}
{isLoading && message.role === 'assistant' && !isStreaming && message.content && (
<span className="inline-block w-2 h-4 ml-0.5 bg-purple-400 animate-pulse align-middle" />
)}
</div>
<div className="text-xs text-slate-500 mt-1">
{fmtTime(message.timestamp)}
{message.isStopped && <span className="ml-2 text-slate-600">(已停止)</span>}
</div>
</div>
</div>
);
})}
{/* 流式输出时占位气泡已包含"思考中"提示,无需独立 spinner */}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<CardContent className="border-t border-slate-700 pt-4">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={configReady ? '输入运维问题,或选择左侧快速操作...' : '请先配置大模型...'}
className="flex-1 bg-slate-900 border-slate-700 text-slate-100"
disabled={isLoading}
/>
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSend} disabled={isLoading || !input.trim()}>
<Send className="h-4 w-4" />
</Button>
{/* 流式生成中显示\"停止生成\"按钮,中止当前请求 // Logan.Li */}
{isLoading && (
<Button
variant="outline"
className="border-red-500/40 text-red-400 hover:bg-red-500/10"
onClick={() => abortRef.current?.abort()}
title="停止生成"
>
<Square className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
</div>
{/* LLM Config Dialog */}
<Dialog open={isConfigOpen} onOpenChange={setIsConfigOpen}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>大模型配置</DialogTitle>
<DialogDescription className="text-slate-500">
配置 AI 助手使用的大语言模型(支持 OpenAI 兼容接口)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto pr-1">
{/* 启用开关 */}
<div className="flex items-center justify-between rounded-lg border border-slate-700 p-3">
<div>
<p className="text-sm font-medium text-slate-200">启用 AI 助手</p>
<p className="text-xs text-slate-500">关闭后将无法进行对话</p>
</div>
<Switch checked={config.enabled} onCheckedChange={(v) => setConfig({ ...config, enabled: v })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">提供商</Label>
<Select value={config.provider} onValueChange={handleProviderChange}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{LLM_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-slate-100">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-300">模型</Label>
{currentProvider && currentProvider.models.length > 0 ? (
<Select value={config.model} onValueChange={(v) => setConfig({ ...config, model: v })}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{currentProvider.models.map((m) => (
<SelectItem key={m} value={m} className="text-slate-100">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={config.model}
onChange={(e) => setConfig({ ...config, model: e.target.value })}
placeholder="输入模型名称"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
)}
</div>
</div>
<div>
<Label className="text-slate-300">API Key {config.provider === 'ollama' && <span className="text-xs text-slate-500">(本地 Ollama 通常无需)</span>}</Label>
<Input
type="password"
value={config.api_key}
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
placeholder="sk-..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">API Endpoint</Label>
<Input
value={config.endpoint}
onChange={(e) => setConfig({ ...config, endpoint: e.target.value })}
placeholder="https://api.openai.com/v1"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono text-sm"
/>
<p className="text-xs text-slate-500 mt-1">基础地址(不含 /chat/completions),OpenAI 兼容格式</p>
</div>
<div>
<Label className="text-slate-300">系统提示词(可选)</Label>
<textarea
value={config.system_prompt}
onChange={(e) => setConfig({ ...config, system_prompt: e.target.value })}
placeholder="留空则使用内置 AIOps 运维助手角色提示词"
className="w-full bg-slate-800 border border-slate-700 text-slate-100 mt-1 rounded-md p-2 text-sm min-h-[70px] resize-y"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">Temperature</Label>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={Math.round(config.temperature * 10) / 10}
onChange={(e) => setConfig({ ...config, temperature: parseFloat(e.target.value) || 0 })}
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<p className="text-xs text-slate-500 mt-1">值越高回答越创造性</p>
</div>
<div>
<Label className="text-slate-300">Max Tokens</Label>
<Input
type="number"
value={config.max_tokens}
onChange={(e) => setConfig({ ...config, max_tokens: parseInt(e.target.value) || 2000 })}
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={handleTest} disabled={testing} className="border-slate-700 text-slate-300">
{testing ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Wifi className="h-4 w-4 mr-2" />}
测试连接
</Button>
<Button variant="outline" onClick={() => setIsConfigOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSaveConfig} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
保存配置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}