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:
1 parent
2e174fe7eb
commit
b8cab7ed0f
6 files changed
+611
-160
No files matched your search
@@ -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: "连接成功"})
|
||||||
|
}
|
||||||
@@ -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)
|
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 秒检查一次)
|
// 启动告警引擎(每 30 秒检查一次)
|
||||||
alertEngine := handlers.NewAlertEngine(db)
|
alertEngine := handlers.NewAlertEngine(db)
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -102,5 +102,6 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.LogEntry{},
|
&models.LogEntry{},
|
||||||
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||||
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
&models.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||||
|
&models.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -23,116 +21,182 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { aiApi, type LLMConfigData } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Server,
|
|
||||||
Activity,
|
Activity,
|
||||||
TrendingUp,
|
|
||||||
FileText,
|
FileText,
|
||||||
|
TrendingUp,
|
||||||
Terminal,
|
Terminal,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
ChevronRight,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Clock,
|
|
||||||
User,
|
User,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Zap,
|
|
||||||
Brain,
|
Brain,
|
||||||
Cpu,
|
|
||||||
BarChart3,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
Wifi,
|
||||||
} from 'lucide-react';
|
} 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 {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
suggestions?: string[];
|
isError?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LLMConfig {
|
// 格式化时间为 HH:MM // Logan.Li
|
||||||
provider: string;
|
function fmtTime(iso: string): string {
|
||||||
model: string;
|
const d = new Date(iso);
|
||||||
apiKey: string;
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
endpoint: string;
|
|
||||||
temperature: number;
|
|
||||||
maxTokens: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
export function AIOpsAssistant() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||||
const [llmConfig, setLlmConfig] = useState<LLMConfig>({
|
const [saving, setSaving] = useState(false);
|
||||||
provider: '',
|
const [testing, setTesting] = useState(false);
|
||||||
model: '',
|
const [config, setConfig] = useState<LLMConfigData>(defaultConfig);
|
||||||
apiKey: '',
|
const [configReady, setConfigReady] = useState(false); // 已配置且启用
|
||||||
endpoint: '',
|
|
||||||
temperature: 0.7,
|
|
||||||
maxTokens: 2000,
|
|
||||||
});
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
// 滚动到底部 // Logan.Li
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
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(() => {
|
// 保存配置 // Logan.Li
|
||||||
scrollToBottom();
|
const handleSaveConfig = async () => {
|
||||||
}, [messages]);
|
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 = () => {
|
// 测试连接 // Logan.Li
|
||||||
if (!input.trim()) return;
|
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(),
|
id: Date.now().toString(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: input,
|
content: text,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
const newMessages = [...messages, userMsg];
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
setMessages(newMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setIsLoading(true);
|
||||||
// AI 功能需配置 LLM 接口,暂不返回模拟回复
|
try {
|
||||||
toast.info('AI 功能需配置 LLM 接口,请在「模型配置」中完成设置');
|
// 仅保留最近 10 条上下文避免 token 超限 // Logan.Li
|
||||||
const assistantMessage: Message = {
|
const history = newMessages.slice(-10).map((m) => ({ role: m.role, content: m.content }));
|
||||||
id: (Date.now() + 1).toString(),
|
const result = await aiApi.chat(history);
|
||||||
role: 'assistant',
|
setMessages((prev) => [
|
||||||
content: '当前未配置 LLM 服务,无法提供智能回复。请在右上角「模型配置」中配置 LLM 接口后重试。',
|
...prev,
|
||||||
timestamp: new Date().toISOString(),
|
{ id: (Date.now() + 1).toString(), role: 'assistant', content: result.content, timestamp: new Date().toISOString() },
|
||||||
};
|
]);
|
||||||
setMessages((prev) => [...prev, assistantMessage]);
|
} 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) => {
|
// 新对话 // Logan.Li
|
||||||
const actionMap: Record<string, string> = {
|
const handleNewChat = () => {
|
||||||
alerts: '分析今日告警',
|
setMessages([]);
|
||||||
performance: '分析系统性能瓶颈',
|
setInput('');
|
||||||
report: '生成今日运维报告',
|
|
||||||
optimize: '提供资源优化建议',
|
|
||||||
};
|
|
||||||
setInput(actionMap[actionId] || '');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentProvider = LLM_PROVIDERS.find((p) => p.id === config.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -146,11 +210,23 @@ export function AIOpsAssistant() {
|
|||||||
<p className="text-sm text-slate-500">AI 驱动的智能运维助手</p>
|
<p className="text-sm text-slate-500">AI 驱动的智能运维助手</p>
|
||||||
</div>
|
</div>
|
||||||
</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)}>
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsConfigOpen(true)}>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
模型配置
|
模型配置
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-6 h-[calc(100vh-200px)]">
|
<div className="grid grid-cols-4 gap-6 h-[calc(100vh-200px)]">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -165,13 +241,10 @@ export function AIOpsAssistant() {
|
|||||||
key={action.id}
|
key={action.id}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start text-slate-300 hover:text-slate-100 hover:bg-slate-700"
|
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" />
|
<action.icon className="h-4 w-4 mr-2 shrink-0" />
|
||||||
<div className="text-left">
|
<span className="text-sm">{action.name}</span>
|
||||||
<p className="text-sm">{action.name}</p>
|
|
||||||
<p className="text-xs text-slate-500">{action.description}</p>
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -209,12 +282,16 @@ export function AIOpsAssistant() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Bot className="h-5 w-5 text-purple-400" />
|
<Bot className="h-5 w-5 text-purple-400" />
|
||||||
<span className="font-medium text-slate-100">AI 助手</span>
|
<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">
|
<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" />
|
<span className="w-2 h-2 rounded-full bg-emerald-400 mr-1 animate-pulse" />
|
||||||
在线
|
在线
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-slate-600 text-slate-400 text-xs">离线</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</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" />
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
新对话
|
新对话
|
||||||
</Button>
|
</Button>
|
||||||
@@ -226,15 +303,17 @@ export function AIOpsAssistant() {
|
|||||||
{messages.length === 0 && !isLoading && (
|
{messages.length === 0 && !isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||||
<MessageSquare className="h-10 w-10 mb-3 opacity-30" />
|
<MessageSquare className="h-10 w-10 mb-3 opacity-30" />
|
||||||
<p>暂无对话记录</p>
|
<p>{configReady ? '暂无对话记录,输入问题开始与 AI 助手对话' : 'AI 助手尚未配置大模型'}</p>
|
||||||
<p className="text-xs mt-1">输入问题开始与 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<div
|
<div key={message.id} className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||||
key={message.id}
|
|
||||||
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}
|
|
||||||
>
|
|
||||||
<Avatar className={message.role === 'assistant' ? 'bg-purple-500' : 'bg-blue-500'}>
|
<Avatar className={message.role === 'assistant' ? 'bg-purple-500' : 'bg-blue-500'}>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{message.role === 'assistant' ? <Bot className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
{message.role === 'assistant' ? <Bot className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
||||||
@@ -242,30 +321,17 @@ export function AIOpsAssistant() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className={`max-w-[80%] ${message.role === 'user' ? 'text-right' : ''}`}>
|
<div className={`max-w-[80%] ${message.role === 'user' ? 'text-right' : ''}`}>
|
||||||
<div
|
<div
|
||||||
className={`inline-block rounded-lg p-3 text-left ${
|
className={`inline-block rounded-lg p-3 text-left whitespace-pre-wrap break-words ${
|
||||||
message.role === 'assistant'
|
message.isError
|
||||||
|
? 'bg-red-900/30 border border-red-500/30 text-red-300'
|
||||||
|
: message.role === 'assistant'
|
||||||
? 'bg-slate-700 text-slate-100'
|
? 'bg-slate-700 text-slate-100'
|
||||||
: 'bg-blue-600 text-white'
|
: 'bg-blue-600 text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
{message.content}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500 mt-1">{message.timestamp}</div>
|
<div className="text-xs text-slate-500 mt-1">{fmtTime(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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -294,10 +360,11 @@ export function AIOpsAssistant() {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
placeholder="输入问题或选择快速操作..."
|
placeholder={configReady ? '输入运维问题,或选择左侧快速操作...' : '请先配置大模型...'}
|
||||||
className="flex-1 bg-slate-900 border-slate-700 text-slate-100"
|
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" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,72 +377,84 @@ export function AIOpsAssistant() {
|
|||||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
|
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>大模型配置</DialogTitle>
|
<DialogTitle>大模型配置</DialogTitle>
|
||||||
<DialogDescription className="text-slate-500">配置 AI 助手使用的大语言模型</DialogDescription>
|
<DialogDescription className="text-slate-500">
|
||||||
|
配置 AI 助手使用的大语言模型(支持 OpenAI 兼容接口)
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-300">提供商</Label>
|
<Label className="text-slate-300">提供商</Label>
|
||||||
<Select
|
<Select value={config.provider} onValueChange={handleProviderChange}>
|
||||||
value={llmConfig.provider}
|
|
||||||
onValueChange={(v) => setLlmConfig({ ...llmConfig, provider: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-slate-800 border-slate-700">
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
{llmProviders.map((p) => (
|
{LLM_PROVIDERS.map((p) => (
|
||||||
<SelectItem key={p.id} value={p.id} className="text-slate-100">
|
<SelectItem key={p.id} value={p.id} className="text-slate-100">{p.name}</SelectItem>
|
||||||
{p.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
{llmProviders.length === 0 && (
|
|
||||||
<div className="px-3 py-2 text-sm text-slate-500">暂无可用的 LLM 提供商</div>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-300">模型</Label>
|
<Label className="text-slate-300">模型</Label>
|
||||||
<Select
|
{currentProvider && currentProvider.models.length > 0 ? (
|
||||||
value={llmConfig.model}
|
<Select value={config.model} onValueChange={(v) => setConfig({ ...config, model: v })}>
|
||||||
onValueChange={(v) => setLlmConfig({ ...llmConfig, model: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-slate-800 border-slate-700">
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
{llmProviders
|
{currentProvider.models.map((m) => (
|
||||||
.find((p) => p.id === llmConfig.provider)
|
<SelectItem key={m} value={m} className="text-slate-100">{m}</SelectItem>
|
||||||
?.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>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</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
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={llmConfig.apiKey}
|
value={config.api_key}
|
||||||
onChange={(e) => setLlmConfig({ ...llmConfig, apiKey: e.target.value })}
|
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-300">API Endpoint (可选)</Label>
|
<Label className="text-slate-300">API Endpoint</Label>
|
||||||
<Input
|
<Input
|
||||||
value={llmConfig.endpoint}
|
value={config.endpoint}
|
||||||
onChange={(e) => setLlmConfig({ ...llmConfig, endpoint: e.target.value })}
|
onChange={(e) => setConfig({ ...config, endpoint: e.target.value })}
|
||||||
placeholder="https://api.openai.com/v1"
|
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>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -386,28 +465,33 @@ export function AIOpsAssistant() {
|
|||||||
min={0}
|
min={0}
|
||||||
max={2}
|
max={2}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={llmConfig.temperature}
|
value={Math.round(config.temperature * 10) / 10}
|
||||||
onChange={(e) => setLlmConfig({ ...llmConfig, temperature: parseFloat(e.target.value) })}
|
onChange={(e) => setConfig({ ...config, temperature: parseFloat(e.target.value) || 0 })}
|
||||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-slate-300">Max Tokens</Label>
|
<Label className="text-slate-300">Max Tokens</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={llmConfig.maxTokens}
|
value={config.max_tokens}
|
||||||
onChange={(e) => setLlmConfig({ ...llmConfig, maxTokens: parseInt(e.target.value) })}
|
onChange={(e) => setConfig({ ...config, max_tokens: parseInt(e.target.value) || 2000 })}
|
||||||
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 variant="outline" onClick={() => setIsConfigOpen(false)} className="border-slate-700 text-slate-300">
|
||||||
取消
|
取消
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -634,23 +634,33 @@ export const dashboardApi = {
|
|||||||
createDatasource: async (_data: any) => ({ success: false }),
|
createDatasource: async (_data: any) => ({ success: false }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// AI Assistant API(无后端)
|
// AI 智能助手 API(后端 LLM 配置 + 对话代理,OpenAI 兼容) // Logan.Li
|
||||||
export const aiAssistantApi = {
|
export interface LLMConfigData {
|
||||||
chat: async (message: string, history: any[]) => {
|
configured?: boolean;
|
||||||
return {
|
provider: string;
|
||||||
data: {
|
model: string;
|
||||||
reply: `我收到了您的问题: "${message}"。作为AIOps智能助手,我可以帮您分析告警、诊断问题、生成报告等。`,
|
api_key: string;
|
||||||
suggestions: ['分析当前告警', '查看性能趋势', '生成运维报告'],
|
endpoint: string;
|
||||||
},
|
temperature: number;
|
||||||
};
|
max_tokens: number;
|
||||||
},
|
system_prompt: string;
|
||||||
analyzeAlert: async (alertId: string) => {
|
enabled: boolean;
|
||||||
return {
|
}
|
||||||
data: {
|
|
||||||
analysis: '根据告警信息分析,可能是由于数据库连接池耗尽导致的。建议检查数据库连接配置和当前连接数。',
|
export interface ChatResult {
|
||||||
recommendations: ['增加连接池大小', '检查慢查询', '重启应用服务'],
|
content: string;
|
||||||
},
|
model: string;
|
||||||
};
|
tokens_used: number;
|
||||||
},
|
}
|
||||||
generateReport: async (params: any) => ({ data: { report: '运维报告已生成...' } }),
|
|
||||||
|
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 }),
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user