- 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 回归通过
696 lines
31 KiB
TypeScript
696 lines
31 KiB
TypeScript
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>
|
||
);
|
||
}
|