From 7be41f0a6bcb3807eb41ea19306610f5309c6066 Mon Sep 17 00:00:00 2001 From: opsx Date: Sat, 31 Oct 2026 06:39:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(aiops):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E8=BF=90=E7=BB=B4=E3=80=8D=E2=80=94=E2=80=94?= =?UTF-8?q?AI=20=E6=97=A5=E5=BF=97=E5=88=86=E6=9E=90=EF=BC=88P0=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E6=89=AB=E6=97=A5=E5=BF=97=E5=86=85=E5=AE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 为什么这样设计(成本优先)=== 全集群 370GB/天,把日志原文丢给模型是不可能的(一次几百万 token)。 所以整条链路只做一件事:**把 GB 级日志压成 KB 级信号**。 P0 的信号来源全部是索引/快照,**零日志内容扫描**: 体积 + 趋势 → index/volume_range(实测 24h 仅 54ms) 条数/流数 → index/stats,且只在 ≤2 小时窗口用 (全天 index/stats 实测 20s,绝不能进 AI 路径) 应用排行 → 平台小时级预聚合快照(毫秒) 基线对比 → 上一个等长窗口的 volume_range 告警 → 平台投递记录 实测一次分析:3~6 秒、输入 600~1400 tokens(约 ¥0.005/次)。 === 后端 === models/ai.go AIConfig(APIKey 加密存储)+ AIAnalysisRun(审计) services/ai_config.go 读写配置、掩码返回、每日 token 用量统计 services/ai_provider.go OpenAI 兼容客户端 + 连通性测试 (DeepSeek/通义/内网 vLLM/Ollama 通用) services/ai_redact.go 脱敏:凭据/邮箱/手机号/身份证 → 占位符, **保留内网 IP 与端口**(排查必需) services/ai_signal.go 信号采集 + 渲染成紧凑文本(省 token) services/ai_analyze.go 结构化输出解析、配额闸门、审计落库 controllers/ai.go /ai/config /ai/config/test /ai/analyze /ai/runs 成本闸门(三层): 1) 单次:时间范围 ≤24h、maxTokens ≤1500 2) 每用户每日 token 上限(默认 50 万,可配,超限直接拒绝并说明) 3) 审计只留最近 500 条,避免表膨胀 === 前端 === 新页面 /aiops(侧栏「智能运维」,管理员),页内单层 tab: 日志分析 / 模型配置 / 分析历史 - 分析:数据源 + 时间预设(15m/1h/6h/24h) + 可选提问 → 结论/证据/建议/不确定 四区 顶部显示本次消耗(in/out tokens、耗时),可查看「发给模型的完整内容」 - 配置:启用开关、Base URL、模型、API Key(掩码)、温度、输出上限、超时、 每日配额、额外脱敏正则;按钮顺序改为「测试连接 → 保存配置」 - 历史:服务端分页(20/页),点行看详情与 payload === 两个真实踩坑(已修)=== 1) 密码含 @ 导致脱敏漏掉(mysql://user:P@ssw0rd@host) → URL basic-auth 规则改为贪婪匹配到最后一个 @,由单测锁定 2) 结论里的时间是 UTC(容器无 tzdata,time.Local=UTC), 模型写"凌晨 6 点下跌"而运维当时是下午 2 点 → 显式用东八区展示(LOKISTACK_DISPLAY_TZ=UTC 可覆盖) === 实测(69147008fcf4)=== 配置保存 → key 掩码 sk-***e286;连通性测试返回「正常」 分析 15 分钟窗口:4.1s,结论识别出"窗口末段体积断崖约 -60%" 信号时间 14:23~14:38(本地时间正确) 历史分页 total=3,tokens/耗时字段完整 单测:脱敏 2 个用例 + 模型输出解析 4 个用例 + 6 条 AI 路由断言 --- backend/controllers/ai.go | 149 +++++ backend/models/ai.go | 49 ++ backend/repository/db.go | 9 + backend/routers/router.go | 13 + backend/routers/router_test.go | 7 + backend/services/ai_analyze.go | 243 +++++++++ backend/services/ai_config.go | 185 +++++++ backend/services/ai_provider.go | 128 +++++ backend/services/ai_redact.go | 64 +++ backend/services/ai_redact_test.go | 87 +++ backend/services/ai_signal.go | 279 ++++++++++ frontend/src/api/ai/index.ts | 94 ++++ frontend/src/i18n/index.ts | 4 + frontend/src/i18n/locales/en/aiops.ts | 98 ++++ frontend/src/i18n/locales/en/common.ts | 3 +- frontend/src/i18n/locales/zh/aiops.ts | 98 ++++ frontend/src/i18n/locales/zh/common.ts | 1 + frontend/src/pages/aiops/index.tsx | 722 +++++++++++++++++++++++++ frontend/src/router/routes.tsx | 4 + 19 files changed, 2236 insertions(+), 1 deletion(-) create mode 100644 backend/controllers/ai.go create mode 100644 backend/models/ai.go create mode 100644 backend/services/ai_analyze.go create mode 100644 backend/services/ai_config.go create mode 100644 backend/services/ai_provider.go create mode 100644 backend/services/ai_redact.go create mode 100644 backend/services/ai_redact_test.go create mode 100644 backend/services/ai_signal.go create mode 100644 frontend/src/api/ai/index.ts create mode 100644 frontend/src/i18n/locales/en/aiops.ts create mode 100644 frontend/src/i18n/locales/zh/aiops.ts create mode 100644 frontend/src/pages/aiops/index.tsx diff --git a/backend/controllers/ai.go b/backend/controllers/ai.go new file mode 100644 index 0000000..26de2e8 --- /dev/null +++ b/backend/controllers/ai.go @@ -0,0 +1,149 @@ +package controllers + +import ( + "fmt" + "strconv" + "time" + + "lokistack/models" + "lokistack/services" + "lokistack/utils" + + "github.com/gin-gonic/gin" +) + +// AIController AIOps:模型配置 + 日志分析 + 审计历史 +type AIController struct{} + +func NewAIController() *AIController { return &AIController{} } + +// GetConfig 返回模型配置(APIKey 掩码)+ 今日已用 token。 +func (ctrl *AIController) GetConfig(c *gin.Context) { + userID := c.GetUint("userId") + cfg := services.MaskedAIConfig() + utils.Success(c, gin.H{ + "config": cfg, + "tokenUsedToday": services.AITokenUsedToday(userID), + }) +} + +// SaveConfig 保存模型配置。apiKey 传空或掩码 = 沿用旧值。 +func (ctrl *AIController) SaveConfig(c *gin.Context) { + var req struct { + models.AIConfig + APIKey string `json:"apiKey"` + } + if !utils.BindAndValidate(c, &req) { + return + } + if err := services.SaveAIConfig(req.AIConfig, req.APIKey); err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"config": services.MaskedAIConfig()}) +} + +// TestConfig 连通性测试:用已保存(或本次提交)的配置发一句最小请求。 +func (ctrl *AIController) TestConfig(c *gin.Context) { + var req struct { + APIKey string `json:"apiKey"` + BaseURL string `json:"baseUrl"` + Model string `json:"model"` + } + _ = c.ShouldBindJSON(&req) + + cfg := services.GetAIConfig() + if req.BaseURL != "" { + cfg.BaseURL = req.BaseURL + } + if req.Model != "" { + cfg.Model = req.Model + } + // 传了新的 key 就用新的(掩码/空则沿用已保存的) + if req.APIKey != "" && !containsMask(req.APIKey) { + cfg.APIKey = req.APIKey + } + + reply, err := services.TestAIConnection(cfg) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"reply": reply, "model": cfg.Model}) +} + +func containsMask(s string) bool { + for i := 0; i+2 < len(s); i++ { + if s[i] == '*' && s[i+1] == '*' && s[i+2] == '*' { + return true + } + } + return false +} + +// Analyze 执行一次日志分析。 +// +// 时间范围在服务端收敛:最大 24 小时。理由不是性能(P0 全走索引,很快), +// 而是"范围越大,信号越笼统,结论越没用",且 token 会线性增长。 +func (ctrl *AIController) Analyze(c *gin.Context) { + var req struct { + SourceID uint `json:"sourceId" binding:"required"` + Start int64 `json:"start" binding:"required"` + End int64 `json:"end" binding:"required"` + Question string `json:"question"` + } + if !utils.BindAndValidate(c, &req) { + return + } + src := services.GetLogSource(req.SourceID) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + from := time.Unix(0, req.Start) + to := time.Unix(0, req.End) + if !to.After(from) { + utils.BadRequest(c, "时间范围无效:结束时间必须晚于开始时间") + return + } + if to.Sub(from) > 24*time.Hour { + utils.BadRequest(c, fmt.Sprintf("时间范围过大(%d 分钟):AI 分析最多支持 24 小时,建议用 15~60 分钟", int(to.Sub(from).Minutes()))) + return + } + + userID := c.GetUint("userId") + username := c.GetString("username") + admin := c.GetString("role") == "admin" + + cfg := services.GetAIConfig() + outcome, err := services.RunAIAnalysis(c.Request.Context(), cfg, src, userID, username, admin, from, to, req.Question) + if err != nil { + // 未配置模型 / 配额用尽 / 上游报错:都给出具体原因,不包装成 500 + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, outcome) +} + +// ListRuns 审计历史(分页)。 +func (ctrl *AIController) ListRuns(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + runs, total := services.ListAIRuns(page, pageSize) + utils.Success(c, gin.H{"list": runs, "total": total, "page": page, "pageSize": pageSize}) +} + +// GetRun 单条审计详情(含脱敏后的完整 payload)。 +func (ctrl *AIController) GetRun(c *gin.Context) { + id, err := utils.IntParam(c, "id") + if err != nil { + utils.BadRequest(c, "ID 无效") + return + } + run := services.GetAIRun(uint(id)) + if run == nil { + utils.Error(c, 404, "记录不存在") + return + } + utils.Success(c, run) +} diff --git a/backend/models/ai.go b/backend/models/ai.go new file mode 100644 index 0000000..8646e9d --- /dev/null +++ b/backend/models/ai.go @@ -0,0 +1,49 @@ +package models + +import "time" + +// AIConfig 大模型接入配置(存在 settings 表里,APIKey 加密保存)。 +// +// 设计取向:只支持 OpenAI 兼容协议 —— DeepSeek / 通义 / 内网 vLLM / Ollama / +// 任意 OpenAI 兼容网关都覆盖了,不针对厂商写适配器,保持"通用"。 +type AIConfig struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` // 固定 openai-compatible(保留字段便于将来扩展) + BaseURL string `json:"baseUrl"` // 如 https://api.deepseek.com/v1 + // APIKey 返回给前端时永远是掩码(sk-***abcd);保存时为空表示"不改动" + APIKey string `json:"apiKey"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"maxTokens"` + TimeoutSec int `json:"timeoutSec"` + // DailyTokenCap 每用户每日 token 上限(成本闸门);0 = 不限制 + DailyTokenCap int `json:"dailyTokenCap"` + // MaxScanBytes 单次分析允许扫描的日志字节上限(成本闸门);0 = 用默认 2GB + MaxScanBytes int64 `json:"maxScanBytes"` + // RedactExtra 额外脱敏正则(每行一个),命中的内容替换为 *** + RedactExtra []string `json:"redactExtra"` +} + +// AIAnalysisRun 一次 AI 分析的审计记录。 +// +// 之所以连 PromptPayload 一起存:出问题时能回答"你到底把什么发给了模型", +// 这是脱敏与合规的底线。payload 已脱敏,且长度在主流程里截断。 +type AIAnalysisRun struct { + ID uint `json:"id"` + UserID uint `json:"userId"` + Username string `json:"username"` + SourceID uint `json:"sourceId"` + SourceName string `json:"sourceName"` + RangeMinutes int `json:"rangeMinutes"` + Model string `json:"model"` + Question string `json:"question"` + Status string `json:"status"` // ok | failed | no-model | quota-exceeded + Error string `json:"error"` + Conclusion string `json:"conclusion"` // 模型返回的结构化 JSON(字符串) + PromptPayload string `json:"promptPayload"` + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + ScanBytes int64 `json:"scanBytes"` + DurationMs int64 `json:"durationMs"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/repository/db.go b/backend/repository/db.go index dc872ab..cfeab96 100644 --- a/backend/repository/db.go +++ b/backend/repository/db.go @@ -46,6 +46,8 @@ type JsonDB struct { DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"` // AppVolumeSnapshots 按小时留存的 per-app 日志量(长范围排行读这个,不扫日志内容) AppVolumeSnapshots []models.AppVolumeSnapshot `json:"appVolumeSnapshots"` + // AIAnalysisRuns AI 分析的审计记录(含脱敏后的 payload,用于合规追溯与每日 token 统计) + AIAnalysisRuns []models.AIAnalysisRun `json:"aiAnalysisRuns"` // SchemaVersion 数据结构版本号。 // 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事 @@ -617,6 +619,7 @@ func (d *JsonDB) Reload() error { d.AuditLogs = fresh.AuditLogs d.DailyVolumeSnapshots = fresh.DailyVolumeSnapshots d.AppVolumeSnapshots = fresh.AppVolumeSnapshots + d.AIAnalysisRuns = fresh.AIAnalysisRuns d.SchemaVersion = fresh.SchemaVersion d.sequences = map[string]uint{} for _, u := range d.Users { @@ -694,6 +697,11 @@ func (d *JsonDB) Reload() error { d.sequences["appVolumeSnapshots"] = v.ID } } + for _, v := range d.AIAnalysisRuns { + if v.ID > d.sequences["aiAnalysisRuns"] { + d.sequences["aiAnalysisRuns"] = v.ID + } + } return nil } @@ -862,6 +870,7 @@ func InitDB() error { "auditLogs": 0, "dailyVolumeSnapshots": 0, "appVolumeSnapshots": 0, + "aiAnalysisRuns": 0, }, saveChan: make(chan saveRequest, 10), stopChan: make(chan struct{}), diff --git a/backend/routers/router.go b/backend/routers/router.go index 198c54c..236a6b3 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -51,6 +51,7 @@ func SetupRouter() *gin.Engine { auditCtrl := controllers.NewAuditController() systemCtrl := controllers.NewSystemController() registryCtrl := controllers.NewImageRegistryController() + aiCtrl := controllers.NewAIController() // API v1 api := r.Group("/api/v1") @@ -121,6 +122,18 @@ func SetupRouter() *gin.Engine { // 镜像源(国内加速/海外/私有仓库) authorized.GET("/settings/image-registry", registryCtrl.Get) + + // ── AIOps:AI 日志分析 ────────────────────────────────────── + // 模型配置仅管理员可改(涉及密钥与费用);分析本身对所有有数据源权限的用户开放。 + ai := authorized.Group("/ai") + { + ai.GET("/config", middleware.AdminAuth(), aiCtrl.GetConfig) + ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig) + ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig) + ai.POST("/analyze", aiCtrl.Analyze) + ai.GET("/runs", aiCtrl.ListRuns) + ai.GET("/runs/:id", aiCtrl.GetRun) + } authorized.PUT("/settings/image-registry", middleware.AdminAuth(), registryCtrl.Save) // 告警:飞书通知配置 + 告警规则 diff --git a/backend/routers/router_test.go b/backend/routers/router_test.go index ab903ec..7f048fe 100644 --- a/backend/routers/router_test.go +++ b/backend/routers/router_test.go @@ -51,6 +51,13 @@ func TestCriticalRoutes(t *testing.T) { // 设置类:保留期必须同时有读和写(写要管理员) {"GET", "/api/v1/settings/retention"}, {"PUT", "/api/v1/settings/retention"}, + // AIOps:模型配置(管理员)+ 分析 + 审计历史 + {"GET", "/api/v1/ai/config"}, + {"PUT", "/api/v1/ai/config"}, + {"POST", "/api/v1/ai/config/test"}, + {"POST", "/api/v1/ai/analyze"}, + {"GET", "/api/v1/ai/runs"}, + {"GET", "/api/v1/ai/runs/:id"}, // 备份恢复接口已在上游被移除(commit f87fb4b),这里不再断言 } diff --git a/backend/services/ai_analyze.go b/backend/services/ai_analyze.go new file mode 100644 index 0000000..28e856e --- /dev/null +++ b/backend/services/ai_analyze.go @@ -0,0 +1,243 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "lokistack/models" + "lokistack/repository" +) + +// AIAnalysisResult 结构化结论。 +// +// 强制模型输出这个结构而不是自由文本,原因: +// 1. 前端能分区渲染(结论/证据/建议/不确定),可读性远好于一段散文; +// 2. 每条结论都要挂 evidence,逼模型把话落在数据上,降低编造; +// 3. unknowns 单独一栏,明确"不知道什么"比编一个答案有价值得多。 +type AIAnalysisResult struct { + Summary string `json:"summary"` + Findings []AIFinding `json:"findings"` + Actions []string `json:"actions"` + Unknowns []string `json:"unknowns"` + RawText string `json:"-"` + ParseErr string `json:"parseError,omitempty"` +} + +type AIFinding struct { + Title string `json:"title"` + Evidence string `json:"evidence"` + Severity string `json:"severity"` // high | medium | low +} + +// AIAnalysisOutcome 一次分析的完整返回(含成本信息)。 +type AIAnalysisOutcome struct { + Result AIAnalysisResult `json:"result"` + Signals *AISignalBundle `json:"signals"` + Prompt string `json:"prompt"` + Run models.AIAnalysisRun `json:"run"` + Warnings []string `json:"warnings"` +} + +const aiSystemPrompt = `你是资深 SRE,负责分析 Loki 日志平台的运行状况。 + +严格规则: +1. 只能基于我给你的"信号数据"下结论。**不许编造**日志内容、错误信息、时间点或服务名。 +2. 每一条结论都必须在 evidence 里引用信号数据中的具体数字或条目。 +3. 如果信号数据不足以判断根因,把它写进 unknowns,不要猜。 +4. 你看不到日志原文(只有统计与排行),因此不要描述"日志里写了什么"。 +5. 输出必须是**单个 JSON 对象**,不要 markdown 代码块,不要任何解释性前后缀。 + +JSON 结构: +{ + "summary": "两三句话的整体判断", + "findings": [{"title": "简短结论", "evidence": "引用具体数据", "severity": "high|medium|low"}], + "actions": ["可执行的下一步动作,按优先级排序"], + "unknowns": ["无法从当前数据判断的点"] +}` + +// RunAIAnalysis 执行一次分析:采集信号 → 脱敏 → 调模型 → 解析 → 落审计。 +func RunAIAnalysis(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, username string, admin bool, from, to time.Time, question string) (*AIAnalysisOutcome, error) { + if !cfg.Enabled { + return nil, fmt.Errorf("AI 分析未启用:请先在「智能运维 → 模型配置」中开启并保存") + } + + // ── 每日配额闸门(成本控制第一层)── + if cfg.DailyTokenCap > 0 { + used := AITokenUsedToday(userID) + if used >= cfg.DailyTokenCap { + run := models.AIAnalysisRun{ + UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name, + Status: "quota-exceeded", Error: fmt.Sprintf("今日 AI token 用量已达上限(%d/%d)", used, cfg.DailyTokenCap), + CreatedAt: time.Now(), + } + persistAIRun(&run) + return nil, fmt.Errorf("今日 AI 用量已达上限(%d/%d tokens),可在模型配置中调整上限或明天再试", used, cfg.DailyTokenCap) + } + } + + started := time.Now() + signals, err := CollectAISignals(ctx, src, userID, admin, from, to) + if err != nil { + return nil, err + } + // 扫描预算闸门:P0 全是索引/快照,实际扫描为 0,这里留出扩展位 + if cfg.MaxScanBytes > 0 && signals.TotalBytes > cfg.MaxScanBytes*8 { + signals.Warnings = append(signals.Warnings, + fmt.Sprintf("该范围日志体积 %.1f GB 偏大,已只使用索引统计(未读取日志内容)", + float64(signals.TotalBytes)/1024/1024/1024)) + } + + // ── 脱敏(第二层:隐私)── + rawPrompt := SignalsAsText(signals) + redacted, invalidRules := RedactText(rawPrompt, cfg.RedactExtra) + if question = strings.TrimSpace(question); question != "" { + redacted += "\n\n运维补充的问题:" + RedactTextOnly(question, cfg.RedactExtra) + } + warnings := append([]string{}, signals.Warnings...) + for _, r := range invalidRules { + warnings = append(warnings, "额外脱敏规则无效已跳过:"+r) + } + + // ── 调模型 ── + reply, promptTokens, completionTokens, callErr := aiChat(ctx, cfg, []aiMessage{ + {Role: "system", Content: aiSystemPrompt}, + {Role: "user", Content: redacted}, + }) + + run := models.AIAnalysisRun{ + UserID: userID, Username: username, SourceID: src.ID, SourceName: src.Name, + RangeMinutes: signals.RangeMinutes, Model: cfg.Model, Question: question, + PromptPayload: truncateStr(redacted, 20000), + PromptTokens: promptTokens, CompletionTokens: completionTokens, + ScanBytes: 0, // P0 不扫内容 + DurationMs: time.Since(started).Milliseconds(), + CreatedAt: time.Now(), + } + if callErr != nil { + run.Status = "failed" + run.Error = callErr.Error() + run.DurationMs = time.Since(started).Milliseconds() + persistAIRun(&run) + return nil, callErr + } + + result := parseAIResult(reply) + conclusion, _ := json.Marshal(result) + run.Status = "ok" + run.Conclusion = string(conclusion) + run.DurationMs = time.Since(started).Milliseconds() + persistAIRun(&run) + + return &AIAnalysisOutcome{ + Result: result, Signals: signals, Prompt: redacted, Run: run, Warnings: warnings, + }, nil +} + +// RedactTextOnly 只脱敏不做规则校验提示(用于短文本如用户提问)。 +func RedactTextOnly(text string, extra []string) string { + out, _ := RedactText(text, extra) + return out +} + +// parseAIResult 宽松解析模型输出。 +// +// 模型常见的"不听话"有三种:包了 ```json 代码块、前后加了解释、 +// 直接返回纯文本。前两种剥壳后重试,第三种降级为 summary=原文, +// 保证用户至少能看到内容,而不是一个"解析失败"。 +func parseAIResult(reply string) AIAnalysisResult { + text := strings.TrimSpace(reply) + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + text = strings.TrimSpace(text) + if idx := strings.Index(text, "{"); idx > 0 { + text = text[idx:] + } + if end := strings.LastIndex(text, "}"); end >= 0 && end < len(text)-1 { + text = text[:end+1] + } + + var parsed AIAnalysisResult + if err := json.Unmarshal([]byte(text), &parsed); err == nil && (parsed.Summary != "" || len(parsed.Findings) > 0) { + return parsed + } + return AIAnalysisResult{ + Summary: strings.TrimSpace(reply), + Findings: []AIFinding{}, + Actions: []string{}, + Unknowns: []string{}, + RawText: reply, + ParseErr: "模型未按 JSON 格式返回,已按纯文本展示", + } +} + +// persistAIRun 落审计(同时用于每日 token 统计)。 +func persistAIRun(run *models.AIAnalysisRun) { + db := repository.GetDB() + if db == nil { + return + } + newID := db.NextID("aiAnalysisRuns") + db.Lock() + defer db.Unlock() + run.ID = newID + db.AIAnalysisRuns = append(db.AIAnalysisRuns, *run) + // 只保留最近 500 条,避免审计表无限膨胀(明细可通过日志追溯) + if len(db.AIAnalysisRuns) > 500 { + db.AIAnalysisRuns = db.AIAnalysisRuns[len(db.AIAnalysisRuns)-500:] + } + db.RequestSave() +} + +// ListAIRuns 分页查询审计记录(按时间倒序)。 +func ListAIRuns(page, pageSize int) ([]models.AIAnalysisRun, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + db := repository.GetDB() + if db == nil { + return nil, 0 + } + db.RLock() + all := make([]models.AIAnalysisRun, len(db.AIAnalysisRuns)) + copy(all, db.AIAnalysisRuns) + db.RUnlock() + + total := len(all) + // 倒序(新的在前) + for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { + all[i], all[j] = all[j], all[i] + } + start := (page - 1) * pageSize + if start >= total { + return []models.AIAnalysisRun{}, total + } + end := start + pageSize + if end > total { + end = total + } + return all[start:end], total +} + +// GetAIRun 取单条审计记录(用于查看完整 payload)。 +func GetAIRun(id uint) *models.AIAnalysisRun { + db := repository.GetDB() + if db == nil { + return nil + } + db.RLock() + defer db.RUnlock() + for i := range db.AIAnalysisRuns { + if db.AIAnalysisRuns[i].ID == id { + run := db.AIAnalysisRuns[i] + return &run + } + } + return nil +} diff --git a/backend/services/ai_config.go b/backend/services/ai_config.go new file mode 100644 index 0000000..12c693b --- /dev/null +++ b/backend/services/ai_config.go @@ -0,0 +1,185 @@ +package services + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "lokistack/models" + "lokistack/pkg/crypto" + "lokistack/repository" +) + +const aiConfigSettingKey = "ai_config" + +// 成本相关的默认值(宁可保守:额度小、扫描少,用户觉得不够再调) +const ( + defaultAIMaxTokens = 1500 + defaultAITimeoutSec = 120 + defaultAITemperature = 0.1 // 分析场景要稳定复现,不要发挥 + defaultAIDailyTokenCap = 500000 + defaultAIMaxScanBytes = 2 << 30 // 2GiB +) + +// GetAIConfig 读配置(返回真实 APIKey,仅供内部调用)。 +func GetAIConfig() models.AIConfig { + cfg := models.AIConfig{ + Provider: "openai-compatible", + Temperature: defaultAITemperature, + MaxTokens: defaultAIMaxTokens, + TimeoutSec: defaultAITimeoutSec, + DailyTokenCap: defaultAIDailyTokenCap, + MaxScanBytes: defaultAIMaxScanBytes, + } + db := repository.GetDB() + if db == nil { + return cfg + } + db.RLock() + raw := "" + for _, s := range db.Settings { + if s.Key == aiConfigSettingKey { + raw = s.Value + break + } + } + db.RUnlock() + if raw == "" { + return cfg + } + var stored models.AIConfig + if err := json.Unmarshal([]byte(raw), &stored); err != nil { + return cfg + } + if stored.APIKey != "" { + stored.APIKey = crypto.DecryptField(stored.APIKey) + } + if stored.Provider == "" { + stored.Provider = "openai-compatible" + } + if stored.MaxTokens <= 0 { + stored.MaxTokens = defaultAIMaxTokens + } + if stored.TimeoutSec <= 0 { + stored.TimeoutSec = defaultAITimeoutSec + } + if stored.MaxScanBytes <= 0 { + stored.MaxScanBytes = defaultAIMaxScanBytes + } + return stored +} + +// MaskedAIConfig 返回给前端的版本:APIKey 只留头尾。 +func MaskedAIConfig() models.AIConfig { + cfg := GetAIConfig() + cfg.APIKey = maskKey(cfg.APIKey) + return cfg +} + +func maskKey(key string) string { + if key == "" { + return "" + } + if len(key) <= 8 { + return "***" + } + return key[:3] + "***" + key[len(key)-4:] +} + +// SaveAIConfig 保存配置。 +// +// apiKey 传空 = 沿用旧值(前端拿到的是掩码,不能把掩码存回去); +// 传 "***" 开头的掩码也视为沿用,避免用户只是改模型名却把 key 清空。 +func SaveAIConfig(in models.AIConfig, apiKey string) error { + old := GetAIConfig() + key := strings.TrimSpace(apiKey) + switch { + case key == "": + key = old.APIKey + case strings.Contains(key, "***"): + key = old.APIKey + } + if in.Enabled && (strings.TrimSpace(in.BaseURL) == "" || strings.TrimSpace(in.Model) == "") { + return fmt.Errorf("启用 AI 分析需要填写 Base URL 与模型名") + } + if in.Enabled && key == "" { + return fmt.Errorf("启用 AI 分析需要填写 API Key(内网无鉴权的服务可填任意占位值)") + } + if in.BaseURL != "" && !strings.HasPrefix(in.BaseURL, "http") { + return fmt.Errorf("Base URL 必须以 http:// 或 https:// 开头(例如 https://api.deepseek.com/v1)") + } + + enc, err := crypto.EncryptField(key) + if err != nil { + return fmt.Errorf("加密 API Key 失败: %w", err) + } + saved := models.AIConfig{ + Enabled: in.Enabled, + Provider: "openai-compatible", + BaseURL: strings.TrimSuffix(strings.TrimSpace(in.BaseURL), "/"), + APIKey: enc, + Model: strings.TrimSpace(in.Model), + Temperature: in.Temperature, + MaxTokens: in.MaxTokens, + TimeoutSec: in.TimeoutSec, + DailyTokenCap: in.DailyTokenCap, + MaxScanBytes: in.MaxScanBytes, + RedactExtra: in.RedactExtra, + } + if saved.MaxTokens <= 0 { + saved.MaxTokens = defaultAIMaxTokens + } + if saved.TimeoutSec <= 0 { + saved.TimeoutSec = defaultAITimeoutSec + } + if saved.MaxScanBytes <= 0 { + saved.MaxScanBytes = defaultAIMaxScanBytes + } + data, err := json.Marshal(saved) + if err != nil { + return err + } + + db := repository.GetDB() + if db == nil { + return fmt.Errorf("数据库未初始化") + } + newID := db.NextID("settings") + db.Lock() + defer db.Unlock() + for i := range db.Settings { + if db.Settings[i].Key == aiConfigSettingKey { + db.Settings[i].Value = string(data) + db.RequestSave() + return nil + } + } + db.Settings = append(db.Settings, models.SystemSetting{ID: newID, Key: aiConfigSettingKey, Value: string(data)}) + db.RequestSave() + return nil +} + +// truncateToDay 取当天 00:00(本地时区),用于"今日已用 token"统计。 +func truncateToDay() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) +} + +// AITokenUsedToday 某用户今日已消耗的 token 总量(每日配额闸门用)。 +func AITokenUsedToday(userID uint) int { + db := repository.GetDB() + if db == nil { + return 0 + } + today := truncateToDay() + db.RLock() + defer db.RUnlock() + total := 0 + for _, r := range db.AIAnalysisRuns { + if r.UserID == userID && !r.CreatedAt.Before(today) { + total += r.PromptTokens + r.CompletionTokens + } + } + return total +} diff --git a/backend/services/ai_provider.go b/backend/services/ai_provider.go new file mode 100644 index 0000000..4317140 --- /dev/null +++ b/backend/services/ai_provider.go @@ -0,0 +1,128 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "lokistack/models" +) + +// OpenAI 兼容 Chat Completions 客户端。 +// +// 只依赖 /chat/completions 这一个端点,所以 DeepSeek、通义、 +// 内网 vLLM、Ollama(/v1)、各类网关都能直接用,不需要按厂商写适配。 + +type aiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type aiChatRequest struct { + Model string `json:"model"` + Messages []aiMessage `json:"messages"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream"` +} + +type aiChatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` +} + +// aiChat 调一次对话补全。 +// +// 错误信息必须具体:把上游返回的 message 原样带出来,不要包装成 +// "调用失败" —— 常见原因(key 错、模型名不存在、baseURL 路径少了 /v1、 +// 内网模型没起)都在上游 message 里,藏起来只会让用户去猜。 +func aiChat(ctx context.Context, cfg models.AIConfig, messages []aiMessage) (string, int, int, error) { + if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Model) == "" { + return "", 0, 0, fmt.Errorf("未配置模型:请先在「智能运维 → 模型配置」填写 Base URL 与模型名") + } + url := strings.TrimSuffix(cfg.BaseURL, "/") + "/chat/completions" + body, err := json.Marshal(aiChatRequest{ + Model: cfg.Model, + Messages: messages, + Temperature: cfg.Temperature, + MaxTokens: cfg.MaxTokens, + Stream: false, + }) + if err != nil { + return "", 0, 0, err + } + + timeout := time.Duration(cfg.TimeoutSec) * time.Second + if timeout <= 0 { + timeout = 120 * time.Second + } + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", 0, 0, fmt.Errorf("构造请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if reqCtx.Err() == context.DeadlineExceeded { + return "", 0, 0, fmt.Errorf("模型响应超时(%d 秒):可调大「超时」或换更快的模型", cfg.TimeoutSec) + } + return "", 0, 0, fmt.Errorf("连接模型服务失败: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + var parsed aiChatResponse + if json.Unmarshal(raw, &parsed) == nil && parsed.Error != nil && parsed.Error.Message != "" { + return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, parsed.Error.Message) + } + return "", 0, 0, fmt.Errorf("模型返回 %d:%s", resp.StatusCode, truncateStr(string(raw), 300)) + } + + var parsed aiChatResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", 0, 0, fmt.Errorf("解析模型响应失败(可能不是 OpenAI 兼容接口): %s", truncateStr(string(raw), 200)) + } + if len(parsed.Choices) == 0 { + return "", 0, 0, fmt.Errorf("模型没有返回任何内容(finish_reason 为空)") + } + return parsed.Choices[0].Message.Content, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, nil +} + +// TestAIConnection 连通性测试:发一句最小请求,验证 baseURL/key/model 三件套。 +func TestAIConnection(cfg models.AIConfig) (string, error) { + cfg.MaxTokens = 32 + cfg.TimeoutSec = 30 + reply, _, _, err := aiChat(context.Background(), cfg, []aiMessage{ + {Role: "user", Content: "只回复两个字:正常"}, + }) + if err != nil { + return "", err + } + return strings.TrimSpace(reply), nil +} diff --git a/backend/services/ai_redact.go b/backend/services/ai_redact.go new file mode 100644 index 0000000..dcda36b --- /dev/null +++ b/backend/services/ai_redact.go @@ -0,0 +1,64 @@ +package services + +import ( + "regexp" + "strings" +) + +// 脱敏:日志里经常夹带凭据,送给外部模型前必须处理。 +// +// 原则: +// - 宁可多脱一点,也不能漏掉凭据; +// - 但**不要脱掉排查必需的信息**:内网 IP、端口、主机名、命名空间、 +// Pod 名、错误码都要保留,否则模型给不出有用结论。 +// - 每条规则都替换成带类型的占位符( / ), +// 这样模型仍能看出"这里原本是个 token",语义不丢。 + +type redactRule struct { + pattern *regexp.Regexp + replacement string +} + +var builtinRedactRules = []redactRule{ + // Bearer / JWT —— 一定是凭据 + {regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9\-._~+/]{12,}=*`), "Bearer "}, + {regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}`), ""}, + // key=value 形式的敏感字段 + {regexp.MustCompile(`(?i)\b(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|secret[_-]?key)\b\s*[=:]\s*("?[^\s"',;]{4,}"?)`), "$1="}, + // URL 里的 basic auth:scheme://user:pass@host + // 密码里可能含 @(如 P@ssw0rd),所以取到**最后一个 @** 之前的所有内容: + // 用贪婪 [^\s/]+ 再回溯到最后一个 @,能正确切出 password 与 host。 + {regexp.MustCompile(`://([^:/\s]+):([^\s/]+)@`), "://$1:@"}, + // AK/SK 形式(云厂商常见 16~40 位字母数字混合,带已知前缀) + {regexp.MustCompile(`\b(AKIA|LTAI|AKID)[A-Za-z0-9]{12,}\b`), ""}, + // 手机号 / 身份证 + {regexp.MustCompile(`\b1[3-9]\d{9}\b`), ""}, + {regexp.MustCompile(`\b\d{17}[\dXx]\b`), ""}, + // 邮箱 + {regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`), ""}, +} + +// RedactText 对文本做脱敏,extra 是用户在设置里加的额外正则。 +// +// 额外正则写错(如括号不闭合)不能让整个分析失败:静默跳过, +// 但上层会把这些失效规则一并提示给用户。 +func RedactText(text string, extra []string) (string, []string) { + out := text + for _, rule := range builtinRedactRules { + out = rule.pattern.ReplaceAllString(out, rule.replacement) + } + var invalid []string + for _, raw := range extra { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + re, err := regexp.Compile(raw) + if err != nil { + invalid = append(invalid, raw) + continue + } + out = re.ReplaceAllString(out, "") + } + return out, invalid +} diff --git a/backend/services/ai_redact_test.go b/backend/services/ai_redact_test.go new file mode 100644 index 0000000..ab06edc --- /dev/null +++ b/backend/services/ai_redact_test.go @@ -0,0 +1,87 @@ +package services + +import ( + "strings" + "testing" +) + +// 脱敏的两条底线: +// 1) 凭据必须挡住(否则把 key 发给外部模型); +// 2) 排查必需的信息必须留着(IP/端口/命名空间/错误码), +// 否则模型给不出有用结论 —— 这比"脱得太干净"更常见。 +func TestRedactText(t *testing.T) { + in := `2026-10-30 12:00:01 ERROR redis client: dial tcp 172.19.16.17:6379: connect: connection refused +password=Sup3rS3cret! user-admin +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmn +url: mysql://appuser:P@ssw0rd@10.2.3.4:3306/cex +contact: ops@example.com phone: 13800138000 +accessKey=AKIAIOSFODNN7EXAMPLE` + + out, invalid := RedactText(in, nil) + if len(invalid) != 0 { + t.Fatalf("内置规则不应产生无效正则: %v", invalid) + } + + mustRedact := []string{"Sup3rS3cret", "eyJhbGciOiJIUzI1NiJ9", "P@ssw0rd", "ops@example.com", "13800138000", "AKIAIOSFODNN7EXAMPLE"} + for _, secret := range mustRedact { + if strings.Contains(out, secret) { + t.Errorf("敏感内容未脱敏: %q\n%s", secret, out) + } + } + + mustKeep := []string{"172.19.16.17:6379", "connection refused", "10.2.3.4:3306", "redis"} + for _, keep := range mustKeep { + if !strings.Contains(out, keep) { + t.Errorf("排查必需的信息被误删: %q\n%s", keep, out) + } + } +} + +// 用户自定义正则写错不能拖垮整次分析:跳过并在返回值里报告。 +func TestRedactTextInvalidExtraRule(t *testing.T) { + out, invalid := RedactText("hello internal-id-12345", []string{`(unclosed`, `internal-id-\d+`}) + if len(invalid) != 1 || invalid[0] != `(unclosed` { + t.Errorf("应报告 1 条无效规则,实际 %v", invalid) + } + if strings.Contains(out, "internal-id-12345") { + t.Errorf("有效自定义规则应生效: %s", out) + } +} + +// 模型经常不按 JSON 返回,解析必须宽容,不能只报"解析失败"。 +func TestParseAIResultTolerance(t *testing.T) { + cases := []struct { + name string + in string + want string // 期望的 summary 片段 + }{ + { + name: "标准 JSON", + in: `{"summary":"日志量正常","findings":[],"actions":[],"unknowns":[]}`, + want: "日志量正常", + }, + { + name: "包了 ```json 代码块", + in: "```json\n{\"summary\":\"体积上涨 2 倍\",\"findings\":[{\"title\":\"t\",\"evidence\":\"e\",\"severity\":\"high\"}],\"actions\":[],\"unknowns\":[]}\n```", + want: "体积上涨 2 倍", + }, + { + name: "前后加了解释", + in: `分析如下:{"summary":"存在告警","findings":[],"actions":[],"unknowns":[]} 希望有帮助`, + want: "存在告警", + }, + { + name: "纯文本(降级)", + in: "看起来一切正常,没有异常日志。", + want: "看起来一切正常", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := parseAIResult(c.in) + if !strings.Contains(got.Summary, c.want) { + t.Errorf("summary 不符: got %q want contains %q", got.Summary, c.want) + } + }) + } +} diff --git a/backend/services/ai_signal.go b/backend/services/ai_signal.go new file mode 100644 index 0000000..284e820 --- /dev/null +++ b/backend/services/ai_signal.go @@ -0,0 +1,279 @@ +package services + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "lokistack/models" + "lokistack/repository" +) + +// AI 分析的"信号包":把 GB 级日志压成 KB 级结构化数据。 +// +// 成本优先原则(P0 的硬约束): +// - 体积/趋势 → index/volume_range(实测 24h 仅 54ms,不读日志内容) +// - 条数/流数 → index/stats,只允许在 ≤2 小时窗口上用 +// (全天 index/stats 实测 20 秒,绝不能进 AI 路径) +// - 应用排行 → 平台按小时预聚合的快照(毫秒级) +// - 告警 → 平台库 +// +// 也就是说:**P0 完全不扫日志内容**,一次分析对 Loki 的压力接近 0。 +type AISignalBundle struct { + SourceID uint `json:"sourceId"` + SourceName string `json:"sourceName"` + Selector string `json:"selector"` + From string `json:"from"` + To string `json:"to"` + RangeMinutes int `json:"rangeMinutes"` + + TotalBytes int64 `json:"totalBytes"` + TotalEntries int64 `json:"totalEntries"` + // EntriesExact 条数是否为精确值(false = 范围过大跳过了 index/stats) + EntriesExact bool `json:"entriesExact"` + + Streams int64 `json:"streams"` + Chunks int64 `json:"chunks"` + + Trend []AITrendPoint `json:"trend"` + Apps []AISignalApp `json:"apps"` + + Baseline *AIBaseline `json:"baseline,omitempty"` + Alerts []AIAlert `json:"alerts"` + + // SnapshotHours 平台按小时预聚合已留存的小时数(影响排行的可信度) + SnapshotHours int `json:"snapshotHours"` + // Warnings 采集过程中的降级说明(如"范围过大,条数为估算") + Warnings []string `json:"warnings"` +} + +// aiDisplayZone 结论中展示时间用的时区。 +// +// 为什么不直接用 time.Local:静态编译的容器镜像通常没有 /usr/share/zoneinfo, +// TZ 也没设,time.Local 就是 UTC —— 结果是模型在结论里写"凌晨 6 点日志下跌", +// 而运维当时是下午 2 点,直接被当成数据错误。 +// 默认东八区(本地运维环境),需要别的时区可用 LOKISTACK_DISPLAY_TZ=UTC 覆盖。 +func aiDisplayZone() *time.Location { + if strings.EqualFold(strings.TrimSpace(os.Getenv("LOKISTACK_DISPLAY_TZ")), "UTC") { + return time.UTC + } + return time.FixedZone("CST", 8*3600) +} + +type AITrendPoint struct { + Time string `json:"time"` + Bytes int64 `json:"bytes"` +} + +type AISignalApp struct { + App string `json:"app"` + Namespace string `json:"namespace"` + Entries int64 `json:"entries"` + Bytes int64 `json:"bytes"` + Share string `json:"share"` +} + +type AIBaseline struct { + // PreviousBytes 上一周期同时长的体积(等长窗口对比) + PreviousBytes int64 `json:"previousBytes"` + CurrentBytes int64 `json:"currentBytes"` + DeltaPercent float64 `json:"deltaPercent"` + ComparisonNote string `json:"comparisonNote"` +} + +type AIAlert struct { + RuleName string `json:"ruleName"` + Status string `json:"status"` + Count int `json:"count"` + CreatedAt string `json:"createdAt"` +} + +// CollectAISignals 采集一次分析所需的全部信号。 +func CollectAISignals(ctx context.Context, src *models.LogSource, userID uint, admin bool, from, to time.Time) (*AISignalBundle, error) { + if src == nil { + return nil, fmt.Errorf("数据源不存在") + } + if !to.After(from) { + return nil, fmt.Errorf("时间范围无效") + } + // 复用查询授权:AI 能看到的数据范围必须与当前用户一致,不多不少 + selector, err := AuthorizedLogQuery(src, userID, admin, `{namespace=~".+"}`) + if err != nil { + return nil, err + } + + bundle := &AISignalBundle{ + SourceID: src.ID, + SourceName: src.Name, + Selector: selector, + // 时间统一按本地时区展示:Loki 返回的是 UTC, + // 直接给模型 UTC 会让结论里出现"凌晨 6 点"这种和用户作息对不上的时间。 + From: from.In(aiDisplayZone()).Format("2006-01-02 15:04"), + To: to.In(aiDisplayZone()).Format("2006-01-02 15:04"), + RangeMinutes: int(to.Sub(from).Minutes()), + Alerts: []AIAlert{}, + Trend: []AITrendPoint{}, + Apps: []AISignalApp{}, + } + + // ── 体积 + 趋势:volume_range(只读索引元数据)── + buckets, err := LokiHistogram(ctx, src, selector, from.UnixNano(), to.UnixNano()) + if err != nil { + return nil, err + } + for _, b := range buckets { + var bytes int64 + switch v := b["count"].(type) { + case int64: + bytes = v + case float64: + bytes = int64(v) + } + ts, _ := b["timestamp"].(string) + if parsed, perr := time.Parse(time.RFC3339, ts); perr == nil { + ts = parsed.In(aiDisplayZone()).Format("15:04") + } + bundle.TotalBytes += bytes + bundle.Trend = append(bundle.Trend, AITrendPoint{Time: ts, Bytes: bytes}) + } + // 趋势只保留尾部若干桶,避免 prompt 过长 + if len(bundle.Trend) > 24 { + bundle.Trend = bundle.Trend[len(bundle.Trend)-24:] + } + + // ── 条数:仅短窗口走 index/stats(长窗口太慢,直接跳过并标注)── + if bundle.RangeMinutes <= 120 { + if st, statErr := LokiStatsCached(ctx, src, selector, from.UnixNano(), to.UnixNano()); statErr == nil { + bundle.TotalEntries = st.Entries + bundle.Streams = st.Streams + bundle.Chunks = st.Chunks + bundle.EntriesExact = true + } else { + bundle.Warnings = append(bundle.Warnings, "条数统计失败:"+truncateStr(statErr.Error(), 120)) + } + } else { + bundle.Warnings = append(bundle.Warnings, "时间范围超过 2 小时,条数为估算(跳过 index/stats:全天统计实测需 20 秒)") + } + + // ── 应用排行:平台按小时预聚合快照 ── + if rows := GetAppRanking(src.ID, from, to, 10, "entries"); len(rows) > 0 { + var total int64 + for _, r := range rows { + total += r.Entries + } + for _, r := range rows { + share := "" + if total > 0 { + share = fmt.Sprintf("%.1f%%", float64(r.Entries)/float64(total)*100) + } + bundle.Apps = append(bundle.Apps, AISignalApp{ + App: r.App, Namespace: r.Namespace, Entries: r.Entries, Bytes: r.Bytes, Share: share, + }) + } + } + hours, _ := AppVolumeCoverageInfo(src.ID) + bundle.SnapshotHours = hours + if len(bundle.Apps) == 0 { + bundle.Warnings = append(bundle.Warnings, "应用排行暂无数据:平台按小时预聚合还在积累(当前留存 0 小时)") + } + + // ── 基线对比:与上一个等长窗口比 ── + prevFrom := from.Add(-to.Sub(from)) + prevTo := from + if prevBuckets, perr := LokiHistogram(ctx, src, selector, prevFrom.UnixNano(), prevTo.UnixNano()); perr == nil { + var prevBytes int64 + for _, b := range prevBuckets { + switch v := b["count"].(type) { + case int64: + prevBytes += v + case float64: + prevBytes += int64(v) + } + } + if prevBytes > 0 || bundle.TotalBytes > 0 { + delta := 0.0 + if prevBytes > 0 { + delta = (float64(bundle.TotalBytes) - float64(prevBytes)) / float64(prevBytes) * 100 + } + bundle.Baseline = &AIBaseline{ + PreviousBytes: prevBytes, + CurrentBytes: bundle.TotalBytes, + DeltaPercent: delta, + ComparisonNote: fmt.Sprintf("与上一个 %d 分钟等长窗口对比", bundle.RangeMinutes), + } + } + } + + // ── 告警:窗口内的投递记录 ── + db := repository.GetDB() + if db != nil { + db.RLock() + for _, d := range db.AlertDeliveries { + if d.CreatedAt.Before(from) || d.CreatedAt.After(to) { + continue + } + bundle.Alerts = append(bundle.Alerts, AIAlert{ + RuleName: d.RuleName, Status: d.Status, Count: d.Count, + CreatedAt: d.CreatedAt.In(aiDisplayZone()).Format("2006-01-02 15:04"), + }) + } + db.RUnlock() + } + sort.Slice(bundle.Alerts, func(i, j int) bool { return bundle.Alerts[i].CreatedAt > bundle.Alerts[j].CreatedAt }) + if len(bundle.Alerts) > 10 { + bundle.Alerts = bundle.Alerts[:10] + } + return bundle, nil +} + +// SignalsAsText 把信号包渲染成给模型看的紧凑文本(不是 JSON,省 token)。 +func SignalsAsText(b *AISignalBundle) string { + var sb strings.Builder + fmt.Fprintf(&sb, "数据源:%s\n", b.SourceName) + fmt.Fprintf(&sb, "时间范围:%s ~ %s(%d 分钟)\n", b.From, b.To, b.RangeMinutes) + fmt.Fprintf(&sb, "日志体积:%.2f GB\n", float64(b.TotalBytes)/1024/1024/1024) + if b.EntriesExact { + fmt.Fprintf(&sb, "日志条数:%d\n", b.TotalEntries) + } else { + sb.WriteString("日志条数:(范围过大未精确统计)\n") + } + if b.Streams > 0 || b.Chunks > 0 { + fmt.Fprintf(&sb, "流数/块数:%d / %d\n", b.Streams, b.Chunks) + } + if b.Baseline != nil { + fmt.Fprintf(&sb, "基线对比:上一等长窗口 %.2f GB → 当前 %.2f GB,变化 %+.1f%%(%s)\n", + float64(b.Baseline.PreviousBytes)/1024/1024/1024, + float64(b.Baseline.CurrentBytes)/1024/1024/1024, + b.Baseline.DeltaPercent, b.Baseline.ComparisonNote) + } + if len(b.Apps) > 0 { + sb.WriteString("\n应用日志量排行(来自平台小时级预聚合):\n") + for i, a := range b.Apps { + fmt.Fprintf(&sb, " %d. %s/%s 条数=%d 占比=%s\n", i+1, a.Namespace, a.App, a.Entries, a.Share) + } + } + if len(b.Trend) > 0 { + sb.WriteString("\n体积趋势(最近分桶):\n") + for _, t := range b.Trend { + fmt.Fprintf(&sb, " %s %.1f MB\n", t.Time, float64(t.Bytes)/1024/1024) + } + } + if len(b.Alerts) > 0 { + sb.WriteString("\n窗口内告警:\n") + for _, a := range b.Alerts { + fmt.Fprintf(&sb, " %s %s 命中=%d 状态=%s\n", a.CreatedAt, a.RuleName, a.Count, a.Status) + } + } else { + sb.WriteString("\n窗口内告警:无\n") + } + if len(b.Warnings) > 0 { + sb.WriteString("\n数据限制说明:\n") + for _, w := range b.Warnings { + fmt.Fprintf(&sb, " - %s\n", w) + } + } + return sb.String() +} diff --git a/frontend/src/api/ai/index.ts b/frontend/src/api/ai/index.ts new file mode 100644 index 0000000..6a8f0df --- /dev/null +++ b/frontend/src/api/ai/index.ts @@ -0,0 +1,94 @@ +import { http } from '@/api/http' + +export interface AIConfig { + enabled: boolean + provider: string + baseUrl: string + /** 后端永远返回掩码(sk-***abcd),保存时留空表示不修改 */ + apiKey: string + model: string + temperature: number + maxTokens: number + timeoutSec: number + dailyTokenCap: number + maxScanBytes: number + redactExtra: string[] +} + +export interface AIConfigResponse { + config: AIConfig + tokenUsedToday: number +} + +export interface AIFinding { + title: string + evidence: string + severity: 'high' | 'medium' | 'low' | string +} + +export interface AIAnalysisResult { + summary: string + findings: AIFinding[] + actions: string[] + unknowns: string[] + parseError?: string +} + +export interface AISignalBundle { + sourceId: number + sourceName: string + selector: string + from: string + to: string + rangeMinutes: number + totalBytes: number + totalEntries: number + entriesExact: boolean + apps: Array<{ app: string; namespace: string; entries: number; bytes: number; share: string }> + alerts: Array<{ ruleName: string; status: string; count: number; createdAt: string }> + baseline?: { previousBytes: number; currentBytes: number; deltaPercent: number; comparisonNote: string } + snapshotHours: number + warnings: string[] +} + +export interface AIRun { + id: number + userId: number + username: string + sourceId: number + sourceName: string + rangeMinutes: number + model: string + question: string + status: string + error: string + conclusion: string + promptPayload: string + promptTokens: number + completionTokens: number + scanBytes: number + durationMs: number + createdAt: string +} + +export interface AIAnalyzeResponse { + result: AIAnalysisResult + signals: AISignalBundle + prompt: string + run: AIRun + warnings: string[] +} + +export const aiApi = { + getConfig: () => http.get('/ai/config'), + saveConfig: (data: Partial) => http.put<{ config: AIConfig }>('/ai/config', data), + testConfig: (data: { baseUrl?: string; model?: string; apiKey?: string }) => + http.post<{ reply: string; model: string }>('/ai/config/test', data, { timeout: 45000 }), + analyze: (data: { sourceId: number; start: number; end: number; question?: string }) => + http.post('/ai/analyze', data, { timeout: 180000 }), + runs: (page: number, pageSize: number) => + http.get<{ list: AIRun[]; total: number; page: number; pageSize: number }>( + `/ai/runs?page=${page}&pageSize=${pageSize}`, + ), + run: (id: number) => http.get(`/ai/runs/${id}`), +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 078bf3d..cdc4397 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -28,6 +28,8 @@ import zhAlerts from './locales/zh/alerts' import enAlerts from './locales/en/alerts' import zhSettings from './locales/zh/settings' import enSettings from './locales/en/settings' +import zhAiops from './locales/zh/aiops' +import enAiops from './locales/en/aiops' const LANG_KEY = 'lokistack.i18n.lang' export type AppLanguage = 'zh' | 'en' @@ -62,6 +64,7 @@ void i18n.use(initReactI18next).init({ users: zhUsers, alerts: zhAlerts, settings: zhSettings, + aiops: zhAiops, }, en: { common: enCommon, @@ -76,6 +79,7 @@ void i18n.use(initReactI18next).init({ users: enUsers, alerts: enAlerts, settings: enSettings, + aiops: enAiops, }, }, lng: detectLanguage(), diff --git a/frontend/src/i18n/locales/en/aiops.ts b/frontend/src/i18n/locales/en/aiops.ts new file mode 100644 index 0000000..57013d6 --- /dev/null +++ b/frontend/src/i18n/locales/en/aiops.ts @@ -0,0 +1,98 @@ +export default { + title: 'AIOps', + subtitle: 'Let a model read log signals: statistics first, conclusions second — raw logs never leave the platform', + + tabs: { + analyze: 'Analyze', + config: 'Model', + history: 'History', + }, + + analyze: { + source: 'Data source', + range: 'Time range', + question: 'Extra question (optional)', + questionPlaceholder: 'e.g. is this volume spike related to a rollout?', + run: 'Analyze', + running: 'Analyzing…', + runHint: 'Reads index and pre-aggregated stats only (no log content) — usually 3–15s', + empty: 'Pick a data source and time range, then click Analyze', + notEnabled: 'AI is disabled — configure and enable it on the Model tab first', + lastRun: 'Cost of this run', + promptTokens: 'input tokens', + completionTokens: 'output tokens', + duration: 'duration', + viewPrompt: 'View what was sent', + promptTitle: 'Exact payload sent to the model (redacted)', + promptHint: 'Passwords, tokens, emails and phone numbers are replaced with placeholders; private IPs and ports are kept for troubleshooting.', + }, + + result: { + summary: 'Assessment', + findings: 'Findings & evidence', + actions: 'Suggested actions', + unknowns: 'What cannot be determined', + severityHigh: 'high', + severityMedium: 'medium', + severityLow: 'low', + noFindings: 'No obvious issues found', + noActions: 'Nothing to suggest', + noUnknowns: 'None', + }, + + signals: { + title: 'Signals used', + volume: 'Volume', + entries: 'Entries', + apps: 'Top apps', + baseline: 'Baseline', + alerts: 'Alerts in window', + trend: 'Volume trend', + exact: 'exact', + estimated: 'estimated', + none: 'none', + }, + + config: { + enabled: 'Enable AI analysis', + enabledHint: 'When off, Analyze returns a clear "not enabled" message', + provider: 'Protocol', + providerHint: 'OpenAI-compatible only: DeepSeek / Qwen / self-hosted vLLM / Ollama(/v1)', + baseUrl: 'Base URL', + baseUrlHint: 'e.g. https://api.deepseek.com/v1 or http://172.19.16.10:8000/v1', + apiKey: 'API Key', + apiKeyHint: 'Stored encrypted and shown masked. Leave empty to keep the current key.', + model: 'Model', + modelHint: 'e.g. deepseek-chat / qwen2.5:14b', + temperature: 'Temperature', + temperatureHint: '0–0.2 recommended for stable analysis', + maxTokens: 'Max output tokens', + timeoutSec: 'Timeout (s)', + dailyTokenCap: 'Daily token cap per user', + dailyTokenCapHint: '0 = unlimited. This is the cost gate; exceeding it blocks analysis for the day', + redactExtra: 'Extra redaction rules', + redactExtraHint: 'One regex per line; matches become . Built-in rules already cover passwords/tokens/emails/phones', + save: 'Save', + test: 'Test connection', + testing: 'Testing…', + testOk: 'Connection OK, model replied: {{reply}}', + usedToday: '{{used}} tokens used today', + }, + + history: { + time: 'Time', + user: 'User', + source: 'Source', + status: 'Status', + tokens: 'Tokens', + duration: 'Duration', + range: 'Range', + empty: 'No analyses yet', + detail: 'Analysis detail', + statusOk: 'ok', + statusFailed: 'failed', + statusQuota: 'quota exceeded', + }, + + commonEmpty: 'No data', +} diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 42538c1..054f5c7 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -16,7 +16,8 @@ export default { collectors: 'Log Collectors', logs: 'Log Query', alerts: 'Alerts', - analysis: 'Log Analysis', + analysis: 'Log analysis', + aiops: 'AIOps', settings: 'General', ldap: 'LDAP', registry: 'Image Registry', diff --git a/frontend/src/i18n/locales/zh/aiops.ts b/frontend/src/i18n/locales/zh/aiops.ts new file mode 100644 index 0000000..ebb6dfc --- /dev/null +++ b/frontend/src/i18n/locales/zh/aiops.ts @@ -0,0 +1,98 @@ +export default { + title: '智能运维', + subtitle: '用大模型解读日志信号:先看统计、再看结论,全程不把日志原文发出去', + + tabs: { + analyze: '日志分析', + config: '模型配置', + history: '分析历史', + }, + + analyze: { + source: '数据源', + range: '时间范围', + question: '补充问题(可选)', + questionPlaceholder: '例如:这次日志量上涨是不是和发布有关?', + run: '开始分析', + running: '分析中…', + runHint: '只读取索引与预聚合统计(不扫日志内容),通常 3~15 秒', + empty: '选好数据源和时间范围后点「开始分析」', + notEnabled: 'AI 分析未启用:请先到「模型配置」填写并开启', + lastRun: '本次消耗', + promptTokens: '输入 token', + completionTokens: '输出 token', + duration: '耗时', + viewPrompt: '查看发给模型的内容', + promptTitle: '发给模型的完整内容(已脱敏)', + promptHint: '日志里出现的密码、Token、邮箱、手机号已替换为占位符;内网 IP 与端口保留用于排查。', + }, + + result: { + summary: '整体判断', + findings: '结论与证据', + actions: '建议动作', + unknowns: '无法判断的部分', + severityHigh: '高', + severityMedium: '中', + severityLow: '低', + noFindings: '没有发现明显问题', + noActions: '暂无建议', + noUnknowns: '无', + }, + + signals: { + title: '本次使用的信号', + volume: '日志体积', + entries: '日志条数', + apps: '应用排行', + baseline: '基线对比', + alerts: '窗口内告警', + trend: '体积趋势', + exact: '精确', + estimated: '估算', + none: '无', + }, + + config: { + enabled: '启用 AI 分析', + enabledHint: '关闭后「开始分析」会直接提示未启用,不影响其它功能', + provider: '协议', + providerHint: '仅支持 OpenAI 兼容接口:DeepSeek / 通义 / 内网 vLLM / Ollama(/v1) 都可', + baseUrl: 'Base URL', + baseUrlHint: '例如 https://api.deepseek.com/v1 或 http://172.19.16.10:8000/v1', + apiKey: 'API Key', + apiKeyHint: '保存后只显示前后几位;留空表示不修改。内网无鉴权服务可填任意占位值', + model: '模型名', + modelHint: '例如 deepseek-chat / qwen2.5:14b', + temperature: '温度', + temperatureHint: '分析场景建议 0~0.2,越小越稳定', + maxTokens: '单次输出上限', + timeoutSec: '超时(秒)', + dailyTokenCap: '每用户每日 token 上限', + dailyTokenCapHint: '0 = 不限制。这是成本闸门,超出当天直接拒绝分析', + redactExtra: '额外脱敏规则', + redactExtraHint: '每行一个正则,命中的内容替换为 。内置规则已覆盖密码/Token/邮箱/手机号', + save: '保存配置', + test: '测试连接', + testing: '测试中…', + testOk: '连接正常,模型回复:{{reply}}', + usedToday: '今日已用 {{used}} tokens', + }, + + history: { + time: '时间', + user: '用户', + source: '数据源', + status: '状态', + tokens: 'Token', + duration: '耗时', + range: '范围', + empty: '还没有分析记录', + detail: '分析详情', + statusOk: '成功', + statusFailed: '失败', + statusQuota: '超出配额', + }, + + commonEmpty: '暂无数据', +} diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 919c83b..79290a8 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -17,6 +17,7 @@ export default { logs: '日志查询', alerts: '日志告警', analysis: '日志分析', + aiops: '智能运维', settings: '通用配置', ldap: 'LDAP 接入', registry: '默认镜像源', diff --git a/frontend/src/pages/aiops/index.tsx b/frontend/src/pages/aiops/index.tsx new file mode 100644 index 0000000..72e9ab5 --- /dev/null +++ b/frontend/src/pages/aiops/index.tsx @@ -0,0 +1,722 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Loader2, Play, Save, Sparkles, PlugZap, History, ShieldCheck } from 'lucide-react' +import { toast } from 'sonner' +import { PageContainer } from '@/components/PageContainer' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { aiApi, type AIAnalyzeResponse, type AIConfig, type AIConfigResponse, type AIRun } from '@/api/ai' +import { logsApi, type LogSource } from '@/api/logs' + +const RANGE_PRESETS = [ + { key: '15m', minutes: 15 }, + { key: '1h', minutes: 60 }, + { key: '6h', minutes: 360 }, + { key: '24h', minutes: 1440 }, +] as const + +function fmtBytes(bytes: number): string { + if (!bytes) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let index = 0 + while (value >= 1024 && index < units.length - 1) { + value /= 1024 + index += 1 + } + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}` +} + +function fmtNum(value: number): string { + if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿` + if (value >= 1e4) return `${(value / 1e4).toFixed(1)} 万` + return String(value) +} + +export default function AiopsPage() { + const { t } = useTranslation('aiops') + const [tab, setTab] = useState('analyze') + + // ── 分析 ── + const [sources, setSources] = useState([]) + const [sourceId, setSourceId] = useState(0) + const [rangeKey, setRangeKey] = useState<(typeof RANGE_PRESETS)[number]['key']>('15m') + const [question, setQuestion] = useState('') + const [running, setRunning] = useState(false) + const [outcome, setOutcome] = useState(null) + const [promptOpen, setPromptOpen] = useState(false) + const [enabled, setEnabled] = useState(false) + + // ── 配置 ── + const [config, setConfig] = useState(null) + const [apiKeyInput, setApiKeyInput] = useState('') + const [redactText, setRedactText] = useState('') + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [usedToday, setUsedToday] = useState(0) + + // ── 历史 ── + const [runs, setRuns] = useState([]) + const [runTotal, setRunTotal] = useState(0) + const [runPage, setRunPage] = useState(1) + const [runsLoading, setRunsLoading] = useState(false) + const [detail, setDetail] = useState(null) + const PAGE_SIZE = 20 + + useEffect(() => { + logsApi + .sources() + .then((res: { list?: LogSource[] }) => { + const list = res?.list ?? [] + setSources(list) + if (list.length > 0) setSourceId((prev: number) => prev || list[0].id) + }) + .catch(() => toast.error('数据源列表加载失败')) + }, []) + + const loadConfig = useCallback(() => { + aiApi + .getConfig() + .then((res: AIConfigResponse) => { + setConfig(res.config) + setEnabled(Boolean(res.config?.enabled)) + setRedactText((res.config?.redactExtra ?? []).join('\n')) + setUsedToday(res.tokenUsedToday ?? 0) + }) + .catch(() => { + /* 非管理员访问配置会 403,属预期 */ + }) + }, []) + + useEffect(() => { + loadConfig() + }, [loadConfig]) + + const loadRuns = useCallback( + (page: number) => { + setRunsLoading(true) + aiApi + .runs(page, PAGE_SIZE) + .then(res => { + setRuns(res.list ?? []) + setRunTotal(res.total ?? 0) + }) + .catch(err => toast.error(err instanceof Error ? err.message : '历史加载失败')) + .finally(() => setRunsLoading(false)) + }, + [], + ) + + useEffect(() => { + if (tab === 'history') loadRuns(runPage) + }, [tab, runPage, loadRuns]) + + const rangeMinutes = useMemo( + () => RANGE_PRESETS.find(r => r.key === rangeKey)?.minutes ?? 15, + [rangeKey], + ) + + const runAnalysis = async () => { + if (!sourceId) { + toast.error('请先选择数据源') + return + } + const end = Date.now() + const start = end - rangeMinutes * 60 * 1000 + setRunning(true) + setOutcome(null) + try { + const res = await aiApi.analyze({ + sourceId, + start: start * 1e6, + end: end * 1e6, + question: question.trim() || undefined, + }) + setOutcome(res) + loadConfig() + } catch (err) { + toast.error(err instanceof Error ? err.message : '分析失败') + } finally { + setRunning(false) + } + } + + const saveConfig = async () => { + if (!config) return + setSaving(true) + try { + const res = await aiApi.saveConfig({ + ...config, + apiKey: apiKeyInput.trim(), + redactExtra: redactText + .split('\n') + .map(line => line.trim()) + .filter(Boolean), + }) + setConfig(res.config) + setApiKeyInput('') + toast.success('配置已保存') + } catch (err) { + toast.error(err instanceof Error ? err.message : '保存失败') + } finally { + setSaving(false) + } + } + + const testConnection = async () => { + setTesting(true) + try { + const res = await aiApi.testConfig({ + baseUrl: config?.baseUrl, + model: config?.model, + apiKey: apiKeyInput.trim() || undefined, + }) + toast.success(t('config.testOk', { reply: res.reply || 'OK' })) + } catch (err) { + toast.error(err instanceof Error ? err.message : '连接失败') + } finally { + setTesting(false) + } + } + + const severityColor = (severity: string) => { + if (severity === 'high') return 'bg-destructive/15 text-destructive' + if (severity === 'medium') return 'bg-warning/15 text-warning' + return 'bg-muted text-muted-foreground' + } + + const totalPages = Math.max(1, Math.ceil(runTotal / PAGE_SIZE)) + + return ( + + + + + + {t('tabs.analyze')} + + + + {t('tabs.config')} + + + + {t('tabs.history')} + + + + {/* ─────────── 分析 ─────────── */} + + {!enabled && ( +
+ {t('analyze.notEnabled')} +
+ )} + + + +
+ + +
+
+ +
+ {RANGE_PRESETS.map(preset => ( + + ))} +
+
+
+ + setQuestion(e.target.value)} + className="h-9" + /> +
+ +
+
{t('analyze.runHint')}
+
+ + {!outcome && !running && ( + + {t('analyze.empty')} + + )} + + {running && ( + + + + {t('analyze.running')} + + + )} + + {outcome && ( + <> + {/* 本次消耗:让成本和延迟是可见的 */} +
+ + {t('analyze.lastRun')}:{t('analyze.promptTokens')} {fmtNum(outcome.run.promptTokens)} ·{' '} + {t('analyze.completionTokens')} {fmtNum(outcome.run.completionTokens)} · {t('analyze.duration')}{' '} + {(outcome.run.durationMs / 1000).toFixed(1)}s + + +
+ + + + {t('result.summary')} + + + {outcome.result.summary || t('commonEmpty')} + + + + + + {t('result.findings')} + + + {(outcome.result.findings ?? []).length === 0 && ( +

{t('result.noFindings')}

+ )} + {(outcome.result.findings ?? []).map((finding, index) => ( +
+
+ + {finding.severity === 'high' + ? t('result.severityHigh') + : finding.severity === 'medium' + ? t('result.severityMedium') + : t('result.severityLow')} + + {finding.title} +
+

{finding.evidence}

+
+ ))} +
+
+ +
+ + + {t('result.actions')} + + + {(outcome.result.actions ?? []).length === 0 ? ( +

{t('result.noActions')}

+ ) : ( +
    + {outcome.result.actions.map((action, index) => ( +
  1. {action}
  2. + ))} +
+ )} +
+
+ + + + + {t('result.unknowns')} + + + + {(outcome.result.unknowns ?? []).length === 0 ? ( +

{t('result.noUnknowns')}

+ ) : ( +
    + {outcome.result.unknowns.map((item, index) => ( +
  • {item}
  • + ))} +
+ )} +
+
+
+ + {/* 信号明细:让用户知道结论是基于什么算出来的 */} + + + {t('signals.title')} + + +
+ + {t('signals.volume')}:{fmtBytes(outcome.signals.totalBytes)} + + + {t('signals.entries')}: + + {outcome.signals.entriesExact ? fmtNum(outcome.signals.totalEntries) : t('signals.estimated')} + + + {outcome.signals.baseline && ( + + {t('signals.baseline')}: + 20 + ? 'text-destructive' + : outcome.signals.baseline.deltaPercent < -20 + ? 'text-warning' + : 'text-foreground' + } + > + {outcome.signals.baseline.deltaPercent > 0 ? '+' : ''} + {outcome.signals.baseline.deltaPercent.toFixed(1)}% + + + )} + + {t('signals.alerts')}:{outcome.signals.alerts.length} + +
+ + {outcome.signals.apps.length > 0 && ( +
+ + + + + + + + + + + {outcome.signals.apps.slice(0, 10).map((app, index) => ( + + + + + + + ))} + +
#{t('signals.apps')}{t('signals.entries')}%
{index + 1} + {app.app || '-'} + /{app.namespace} + {fmtNum(app.entries)}{app.share}
+
+ )} + + {(outcome.warnings ?? []).length > 0 && ( +
    + {outcome.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ )} +
+
+ + )} +
+ + {/* ─────────── 模型配置 ─────────── */} + + {!config ? ( + + + 仅管理员可修改模型配置 + + + ) : ( + + + 模型接入(OpenAI 兼容) + + +
+ setConfig({ ...config, enabled: checked })} + /> +
+
{t('config.enabled')}
+
{t('config.enabledHint')}
+
+ + {t('config.usedToday', { used: fmtNum(usedToday) })} + +
+ +
+
+ + setConfig({ ...config, baseUrl: e.target.value })} + /> +

{t('config.baseUrlHint')}

+
+
+ + setConfig({ ...config, model: e.target.value })} + /> +

{t('config.modelHint')}

+
+
+ + setApiKeyInput(e.target.value)} + /> +

{t('config.apiKeyHint')}

+
+
+
+ + setConfig({ ...config, temperature: Number(e.target.value) })} + /> +
+
+ + setConfig({ ...config, maxTokens: Number(e.target.value) })} + /> +
+
+ + setConfig({ ...config, timeoutSec: Number(e.target.value) })} + /> +
+
+
+ + setConfig({ ...config, dailyTokenCap: Number(e.target.value) })} + /> +

{t('config.dailyTokenCapHint')}

+
+
+ +