feat: AIOps智能助手接入真实LLM后端(OpenAI兼容代理+配置持久化)

后端:
- models/llm.go: LLMConfig模型(单行配置)+请求/响应struct
- handlers/llm.go: GetLLMConfig/SaveLLMConfig(脱敏+upsert)/Chat(OpenAI兼容代理)/TestLLMConnection
- routes.go: 注册 /aiops/llm-config(GET/PUT/test)+/aiops/chat(POST)
- database.go: AutoMigrate LLMConfig
- 支持OpenAI/DeepSeek/通义千问/Moonshot/Ollama/自定义6种提供商
- API Key脱敏(仅保留后4位)+回传脱敏值保留原值
- 优雅错误处理(连接超时/401/未配置均返回友好消息)
- 120s超时+AIOps运维助手默认系统提示词

前端:
- AIOpsAssistant.tsx: 全量重写接入真实后端
  · 配置弹窗从后端加载(持久化),提供商切换自动填充endpoint/模型
  · 启用开关+测试连接按钮+保存反馈
  · 真实对话(发送最近10条上下文),思考中spinner,错误红色气泡
  · 状态徽章(未配置/在线+模型名),新对话清空
- api.ts: aiApi替代mock aiAssistantApi(getConfig/saveConfig/testConnection/chat)

验证: tsc零错误+build通过+/health正常+前后端联调通过(配置CRUD+API Key脱敏+masked-key保留+chat错误优雅降级) // Logan.Li
This commit is contained in:
attacker committed 2026-06-18 02:12:17 +08:00
1 parent 2e174fe7eb
commit b8cab7ed0f
6 files changed
+611 -160

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// LLMHandler 大语言模型配置 + 对话代理 // Logan.Li
type LLMHandler struct {
db *gorm.DB
client *http.Client
}
// NewLLMHandler 创建 LLM 处理器 // Logan.Li
func NewLLMHandler(db *gorm.DB) *LLMHandler {
return &LLMHandler{
db: db,
// 超时控制:LLM 推理可能较慢,给 120s
client: &http.Client{Timeout: 120 * time.Second},
}
}
// maskAPIKey 脱敏 API Key(仅保留后 4 位) // Logan.Li
func maskAPIKey(key string) string {
if len(key) <= 4 {
return strings.Repeat("*", len(key))
}
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
}
// getActiveConfig 读取启用的配置(脱敏后) // Logan.Li
func (h *LLMHandler) getActiveConfig(maskKey bool) (models.LLMConfig, error) {
var cfg models.LLMConfig
if err := h.db.First(&cfg).Error; err != nil {
return cfg, err
}
if maskKey {
cfg.APIKey = maskAPIKey(cfg.APIKey)
}
return cfg, nil
}
// GetLLMConfig 获取 LLM 配置(API Key 脱敏) // Logan.Li
// GET /api/v1/aiops/llm-config
func (h *LLMHandler) GetLLMConfig(c *gin.Context) {
cfg, err := h.getActiveConfig(true)
if err != nil {
// 未配置返回空对象(前端渲染空表单)
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"provider": "", "model": "", "api_key": "",
"endpoint": "", "temperature": 0.7, "max_tokens": 2000,
"system_prompt": "", "enabled": false, "configured": false,
}})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
"id": cfg.ID,
"provider": cfg.Provider,
"model": cfg.Model,
"api_key": cfg.APIKey,
"endpoint": cfg.Endpoint,
"temperature": cfg.Temperature,
"max_tokens": cfg.MaxTokens,
"system_prompt": cfg.SystemPrompt,
"enabled": cfg.Enabled,
"configured": true,
}})
}
// SaveLLMConfig 保存 LLM 配置(upsert 单行) // Logan.Li
// PUT /api/v1/aiops/llm-config
func (h *LLMHandler) SaveLLMConfig(c *gin.Context) {
var req models.SaveLLMConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
var cfg models.LLMConfig
h.db.First(&cfg)
// 若前端回传脱敏值(含 ****),保留原 Key
if strings.Contains(req.APIKey, "****") && cfg.ID != 0 {
req.APIKey = cfg.APIKey
}
cfg.Provider = req.Provider
cfg.Model = req.Model
cfg.APIKey = req.APIKey
cfg.Endpoint = req.Endpoint
cfg.Temperature = req.Temperature
cfg.MaxTokens = req.MaxTokens
cfg.SystemPrompt = req.SystemPrompt
cfg.Enabled = req.Enabled
if cfg.ID == 0 {
if err := h.db.Create(&cfg).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "保存失败: " + err.Error()})
return
}
} else {
if err := h.db.Save(&cfg).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "保存失败: " + err.Error()})
return
}
}
// 返回脱敏后的配置
cfg.APIKey = maskAPIKey(cfg.APIKey)
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "保存成功", Data: gin.H{
"id": cfg.ID,
"provider": cfg.Provider,
"model": cfg.Model,
"api_key": cfg.APIKey,
"endpoint": cfg.Endpoint,
"temperature": cfg.Temperature,
"max_tokens": cfg.MaxTokens,
"system_prompt": cfg.SystemPrompt,
"enabled": cfg.Enabled,
"configured": true,
}})
}
// OpenAI 兼容请求/响应结构(仅取必要字段) // Logan.Li
type openaiChatRequest struct {
Model string `json:"model"`
Messages []models.ChatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type openaiChoice struct {
Message models.ChatMessage `json:"message"`
}
type openaiChatResponse struct {
Choices []openaiChoice `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
// resolveEndpoint 根据提供商补全默认 endpoint // Logan.Li
func resolveEndpoint(provider, endpoint string) string {
if endpoint != "" {
return strings.TrimRight(endpoint, "/")
}
switch provider {
case "deepseek":
return "https://api.deepseek.com/v1"
case "qwen":
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "moonshot":
return "https://api.moonshot.cn/v1"
case "ollama":
return "http://localhost:11434/v1"
default: // openai / custom
return "https://api.openai.com/v1"
}
}
// defaultSystemPrompt AIOps 运维助手默认系统提示词 // Logan.Li
const defaultSystemPrompt = `你是 EZOps AIOps 智能运维助手,专注于企业级运维场景。你可以:
1. 分析服务器、容器、K8s 集群的性能瓶颈与异常
2. 解读告警事件并给出根因分析与处置建议
3. 生成 Shell / Ansible / Python 运维脚本
4. 提供容量规划、成本优化、高可用架构建议
回答请简洁、专业、可执行,优先给出具体命令或步骤。使用中文回答。`
// Chat AI 对话代理(OpenAI 兼容接口) // Logan.Li
// POST /api/v1/aiops/chat
func (h *LLMHandler) Chat(c *gin.Context) {
var cfg models.LLMConfig
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
c.JSON(http.StatusOK, models.Response{Code: 400, Message: "LLM 服务未配置或未启用,请在「模型配置」中完成设置"})
return
}
var req models.ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
// 组装系统提示词
systemPrompt := cfg.SystemPrompt
if strings.TrimSpace(systemPrompt) == "" {
systemPrompt = defaultSystemPrompt
}
messages := append([]models.ChatMessage{{Role: "system", Content: systemPrompt}}, req.Messages...)
// 构造 OpenAI 兼容请求
payload := openaiChatRequest{
Model: cfg.Model,
Messages: messages,
Temperature: cfg.Temperature,
MaxTokens: cfg.MaxTokens,
}
body, _ := json.Marshal(payload)
endpoint := resolveEndpoint(cfg.Provider, cfg.Endpoint)
url := endpoint + "/chat/completions"
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, url, bytes.NewReader(body))
if err != nil {
c.JSON(http.StatusOK, models.Response{Code: 500, Message: "构造请求失败: " + err.Error()})
return
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := h.client.Do(httpReq)
if err != nil {
c.JSON(http.StatusOK, models.Response{Code: 502, Message: "LLM 服务连接失败,请检查网络或 Endpoint 配置: " + err.Error()})
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
// 提取上游错误信息
msg := fmt.Sprintf("LLM 服务返回错误 (HTTP %d)", resp.StatusCode)
if upErr := parseOpenAIError(raw); upErr != "" {
msg += ": " + upErr
}
c.JSON(http.StatusOK, models.Response{Code: resp.StatusCode, Message: msg})
return
}
var chatResp openaiChatResponse
if err := json.Unmarshal(raw, &chatResp); err != nil || len(chatResp.Choices) == 0 {
c.JSON(http.StatusOK, models.Response{Code: 500, Message: "解析 LLM 响应失败"})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ChatResponse{
Content: chatResp.Choices[0].Message.Content,
Model: cfg.Model,
TokensUsed: chatResp.Usage.TotalTokens,
}})
}
// parseOpenAIError 从上游错误响应中提取 message 字段 // Logan.Li
func parseOpenAIError(raw []byte) string {
var m struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(raw, &m) == nil && m.Error.Message != "" {
return m.Error.Message
}
return ""
}
// TestLLMConnection 测试 LLM 连接(发送一条 ping 消息) // Logan.Li
// POST /api/v1/aiops/llm-config/test
func (h *LLMHandler) TestLLMConnection(c *gin.Context) {
var cfg models.LLMConfig
if err := h.db.First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, models.Response{Code: 400, Message: "请先保存配置"})
return
}
payload := openaiChatRequest{
Model: cfg.Model,
Messages: []models.ChatMessage{{Role: "user", Content: "ping"}},
Temperature: 0,
MaxTokens: 16,
}
body, _ := json.Marshal(payload)
endpoint := resolveEndpoint(cfg.Provider, cfg.Endpoint)
httpReq, _ := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint+"/chat/completions", bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := h.client.Do(httpReq)
if err != nil {
c.JSON(http.StatusOK, models.Response{Code: 502, Message: "连接失败: " + err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
msg := fmt.Sprintf("HTTP %d", resp.StatusCode)
if e := parseOpenAIError(raw); e != "" {
msg += ": " + e
}
c.JSON(http.StatusOK, models.Response{Code: resp.StatusCode, Message: msg})
return
}
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "连接成功"})
}
+10
View File
@@ -413,6 +413,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
agentQuery.POST("/alerts/:id/resolve", taskHandler.ResolveAlert)
}
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
llmHandler := handlers.NewLLMHandler(db)
aiopsGroup := authorized.Group("/aiops")
{
aiopsGroup.GET("/llm-config", llmHandler.GetLLMConfig)
aiopsGroup.PUT("/llm-config", llmHandler.SaveLLMConfig)
aiopsGroup.POST("/llm-config/test", llmHandler.TestLLMConnection)
aiopsGroup.POST("/chat", llmHandler.Chat)
}
// 启动告警引擎(每 30 秒检查一次)
alertEngine := handlers.NewAlertEngine(db)
go func() {
+52
View File
@@ -0,0 +1,52 @@
package models
import "time"
// LLMConfig 大语言模型配置(单行配置,类似 LDAPConfig) // Logan.Li
// 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 通义千问 / Moonshot / Ollama / 自定义)
type LLMConfig struct {
ID uint `gorm:"primarykey" json:"id"`
Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // openai/deepseek/qwen/moonshot/ollama/custom
Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-chat
APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏)
Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1
Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2
MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token
SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色)
Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名 // Logan.Li
func (LLMConfig) TableName() string { return "llm_configs" }
// SaveLLMConfigRequest 保存 LLM 配置请求 // Logan.Li
type SaveLLMConfigRequest struct {
Provider string `json:"provider"`
Model string `json:"model"`
APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值
Endpoint string `json:"endpoint"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
SystemPrompt string `json:"system_prompt"`
Enabled bool `json:"enabled"`
}
// ChatMessage 对话消息 // Logan.Li
type ChatMessage struct {
Role string `json:"role"` // system / user / assistant
Content string `json:"content"`
}
// ChatRequest AI 对话请求 // Logan.Li
type ChatRequest struct {
Messages []ChatMessage `json:"messages" binding:"required,min=1,dive"`
}
// ChatResponse AI 对话响应 // Logan.Li
type ChatResponse struct {
Content string `json:"content"`
Model string `json:"model"`
TokensUsed int `json:"tokens_used"`
}
+1
View File
@@ -102,5 +102,6 @@ func AutoMigrate(db *gorm.DB) error {
&models.LogEntry{},
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
&models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
)
}
+225 -141
View File
@@ -3,7 +3,6 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
@@ -15,7 +14,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
@@ -23,116 +21,182 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { aiApi, type LLMConfigData } from '@/services/api';
import {
Bot,
Send,
Settings,
Sparkles,
AlertTriangle,
Server,
Activity,
TrendingUp,
FileText,
TrendingUp,
Terminal,
RotateCw,
ChevronRight,
Copy,
Check,
Clock,
User,
MessageSquare,
Zap,
Brain,
Cpu,
BarChart3,
Loader2,
CheckCircle2,
Wifi,
} from 'lucide-react';
// 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
const quickActions = [
{ id: 'alerts', name: '分析今日告警', icon: AlertTriangle, prompt: '帮我分析最近的告警事件,给出根因分析和处置建议' },
{ id: 'performance', name: '性能瓶颈分析', icon: Activity, prompt: '分析当前系统的性能瓶颈,找出 CPU/内存/磁盘使用率最高的服务' },
{ id: 'report', name: '生成运维报告', icon: FileText, prompt: '生成今日运维报告,包括资源使用、告警、发布情况' },
{ id: 'optimize', name: '资源优化建议', icon: TrendingUp, prompt: '根据当前资源使用情况提供成本优化和容量规划建议' },
];
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
suggestions?: string[];
isError?: boolean;
}
interface LLMConfig {
provider: string;
model: string;
apiKey: string;
endpoint: string;
temperature: number;
maxTokens: number;
// 格式化时间为 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' });
}
// LLM 提供商列表(需后端配置后填充)
const llmProviders: { id: string; name: string; models: string[] }[] = [];
const quickActions = [
{ id: 'alerts', name: '分析今日告警', icon: AlertTriangle, description: '分析今日所有告警事件' },
{ id: 'performance', name: '性能瓶颈分析', icon: Activity, description: '分析系统性能瓶颈' },
{ id: 'report', name: '生成运维报告', icon: FileText, description: '生成每日运维报告' },
{ id: 'optimize', name: '资源优化建议', icon: TrendingUp, description: '提供资源优化方案' },
];
export function AIOpsAssistant() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [llmConfig, setLlmConfig] = useState<LLMConfig>({
provider: '',
model: '',
apiKey: '',
endpoint: '',
temperature: 0.7,
maxTokens: 2000,
});
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<LLMConfigData>(defaultConfig);
const [configReady, setConfigReady] = useState(false); // 已配置且启用
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
// 滚动到底部 // 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);
}
};
useEffect(() => { loadConfig(); }, []);
// 切换提供商时自动填充默认 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,
}));
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 保存配置 // 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 any).configured));
toast.success('配置已保存');
setIsConfigOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
const handleSend = () => {
if (!input.trim()) return;
// 测试连接 // 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);
}
};
const userMessage: Message = {
// 发送消息(真实 LLM 对话) // Logan.Li
const handleSend = async () => {
const text = input.trim();
if (!text || isLoading) return;
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
content: text,
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
// AI 功能需配置 LLM 接口,暂不返回模拟回复
toast.info('AI 功能需配置 LLM 接口,请在「模型配置」中完成设置');
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '当前未配置 LLM 服务,无法提供智能回复。请在右上角「模型配置」中配置 LLM 接口后重试。',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(true);
try {
// 仅保留最近 10 条上下文避免 token 超限 // Logan.Li
const history = newMessages.slice(-10).map((m) => ({ role: m.role, content: m.content }));
const result = await aiApi.chat(history);
setMessages((prev) => [
...prev,
{ id: (Date.now() + 1).toString(), role: 'assistant', content: result.content, timestamp: new Date().toISOString() },
]);
} catch (e) {
const errMsg = e instanceof Error ? e.message : '对话失败';
setMessages((prev) => [
...prev,
{ id: (Date.now() + 1).toString(), role: 'assistant', content: `⚠️ ${errMsg}`, timestamp: new Date().toISOString(), isError: true },
]);
} finally {
setIsLoading(false);
}
};
const handleQuickAction = (actionId: string) => {
const actionMap: Record<string, string> = {
alerts: '分析今日告警',
performance: '分析系统性能瓶颈',
report: '生成今日运维报告',
optimize: '提供资源优化建议',
};
setInput(actionMap[actionId] || '');
// 新对话 // Logan.Li
const handleNewChat = () => {
setMessages([]);
setInput('');
};
const currentProvider = LLM_PROVIDERS.find((p) => p.id === config.provider);
return (
<div className="space-y-6">
{/* Header */}
@@ -146,11 +210,23 @@ export function AIOpsAssistant() {
<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 */}
@@ -165,13 +241,10 @@ export function AIOpsAssistant() {
key={action.id}
variant="ghost"
className="w-full justify-start text-slate-300 hover:text-slate-100 hover:bg-slate-700"
onClick={() => handleQuickAction(action.id)}
onClick={() => setInput(action.prompt)}
>
<action.icon className="h-4 w-4 mr-2" />
<div className="text-left">
<p className="text-sm">{action.name}</p>
<p className="text-xs text-slate-500">{action.description}</p>
</div>
<action.icon className="h-4 w-4 mr-2 shrink-0" />
<span className="text-sm">{action.name}</span>
</Button>
))}
</CardContent>
@@ -209,12 +282,16 @@ export function AIOpsAssistant() {
<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">
<Button variant="ghost" size="sm" className="text-slate-400" onClick={handleNewChat} disabled={messages.length === 0}>
<RotateCw className="h-4 w-4 mr-1" />
新对话
</Button>
@@ -226,15 +303,17 @@ export function AIOpsAssistant() {
{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>暂无对话记录</p>
<p className="text-xs mt-1">输入问题开始与 AI 助手对话</p>
<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) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<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" />}
@@ -242,30 +321,17 @@ export function AIOpsAssistant() {
</Avatar>
<div className={`max-w-[80%] ${message.role === 'user' ? 'text-right' : ''}`}>
<div
className={`inline-block rounded-lg p-3 text-left ${
message.role === 'assistant'
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'
}`}
>
<div className="whitespace-pre-wrap">{message.content}</div>
{message.content}
</div>
<div className="text-xs text-slate-500 mt-1">{message.timestamp}</div>
{message.suggestions && message.role === 'assistant' && (
<div className="flex gap-2 mt-2">
{message.suggestions.map((suggestion, idx) => (
<Button
key={idx}
variant="outline"
size="sm"
className="border-slate-600 text-slate-300 text-xs"
onClick={() => setInput(suggestion)}
>
{suggestion}
</Button>
))}
</div>
)}
<div className="text-xs text-slate-500 mt-1">{fmtTime(message.timestamp)}</div>
</div>
</div>
))}
@@ -294,10 +360,11 @@ export function AIOpsAssistant() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入问题或选择快速操作..."
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}>
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSend} disabled={isLoading || !input.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
@@ -310,72 +377,84 @@ export function AIOpsAssistant() {
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>大模型配置</DialogTitle>
<DialogDescription className="text-slate-500">配置 AI 助手使用的大语言模型</DialogDescription>
<DialogDescription className="text-slate-500">
配置 AI 助手使用的大语言模型(支持 OpenAI 兼容接口)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<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={llmConfig.provider}
onValueChange={(v) => setLlmConfig({ ...llmConfig, provider: v })}
>
<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">
{llmProviders.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-slate-100">
{p.name}
</SelectItem>
{LLM_PROVIDERS.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-slate-100">{p.name}</SelectItem>
))}
{llmProviders.length === 0 && (
<div className="px-3 py-2 text-sm text-slate-500">暂无可用的 LLM 提供商</div>
)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-300">模型</Label>
<Select
value={llmConfig.model}
onValueChange={(v) => setLlmConfig({ ...llmConfig, model: v })}
>
{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">
{llmProviders
.find((p) => p.id === llmConfig.provider)
?.models.map((m) => (
<SelectItem key={m} value={m} className="text-slate-100">
{m}
</SelectItem>
{currentProvider.models.map((m) => (
<SelectItem key={m} value={m} className="text-slate-100">{m}</SelectItem>
))}
{!llmProviders.find((p) => p.id === llmConfig.provider)?.models?.length && (
<div className="px-3 py-2 text-sm text-slate-500">请先选择提供商</div>
)}
</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</Label>
<Label className="text-slate-300">API Key {config.provider === 'ollama' && <span className="text-xs text-slate-500">(本地 Ollama 通常无需)</span>}</Label>
<Input
type="password"
value={llmConfig.apiKey}
onChange={(e) => setLlmConfig({ ...llmConfig, apiKey: e.target.value })}
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>
<Label className="text-slate-300">API Endpoint</Label>
<Input
value={llmConfig.endpoint}
onChange={(e) => setLlmConfig({ ...llmConfig, endpoint: e.target.value })}
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"
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">
@@ -386,28 +465,33 @@ export function AIOpsAssistant() {
min={0}
max={2}
step={0.1}
value={llmConfig.temperature}
onChange={(e) => setLlmConfig({ ...llmConfig, temperature: parseFloat(e.target.value) })}
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>
<p className="text-xs text-slate-500 mt-1">值越高回答越创造性</p>
</div>
<div>
<Label className="text-slate-300">Max Tokens</Label>
<Input
type="number"
value={llmConfig.maxTokens}
onChange={(e) => setLlmConfig({ ...llmConfig, maxTokens: parseInt(e.target.value) })}
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>
<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={() => setIsConfigOpen(false)}>
<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>
+29 -19
View File
@@ -634,23 +634,33 @@ export const dashboardApi = {
createDatasource: async (_data: any) => ({ success: false }),
};
// AI Assistant API(无后端)
export const aiAssistantApi = {
chat: async (message: string, history: any[]) => {
return {
data: {
reply: `我收到了您的问题: "${message}"。作为AIOps智能助手,我可以帮您分析告警、诊断问题、生成报告等。`,
suggestions: ['分析当前告警', '查看性能趋势', '生成运维报告'],
},
};
},
analyzeAlert: async (alertId: string) => {
return {
data: {
analysis: '根据告警信息分析,可能是由于数据库连接池耗尽导致的。建议检查数据库连接配置和当前连接数。',
recommendations: ['增加连接池大小', '检查慢查询', '重启应用服务'],
},
};
},
generateReport: async (params: any) => ({ data: { report: '运维报告已生成...' } }),
// AI 智能助手 API(后端 LLM 配置 + 对话代理,OpenAI 兼容) // Logan.Li
export interface LLMConfigData {
configured?: boolean;
provider: string;
model: string;
api_key: string;
endpoint: string;
temperature: number;
max_tokens: number;
system_prompt: string;
enabled: boolean;
}
export interface ChatResult {
content: string;
model: string;
tokens_used: number;
}
export const aiApi = {
// 获取 LLM 配置(API Key 已脱敏)
getConfig: () => http.get<LLMConfigData>('/aiops/llm-config'),
// 保存 LLM 配置(api_key 若为脱敏值则后端保留原值)
saveConfig: (data: LLMConfigData) => http.put<LLMConfigData>('/aiops/llm-config', data),
// 测试 LLM 连接(发送 ping)
testConnection: () => http.post('/aiops/llm-config/test'),
// AI 对话(messages 含历史 + 当前用户消息)
chat: (messages: { role: 'user' | 'assistant'; content: string }[]) =>
http.post<ChatResult>('/aiops/chat', { messages }),
};