diff --git a/backend/controllers/ai.go b/backend/controllers/ai.go index 6adfc6f..7626075 100644 --- a/backend/controllers/ai.go +++ b/backend/controllers/ai.go @@ -153,3 +153,31 @@ func (ctrl *AIController) GetRun(c *gin.Context) { } utils.Success(c, run) } + +// Chat 全站 AI 助手浮窗(多轮对话 + 页面上下文注入;Pro 闸门) +func (ctrl *AIController) Chat(c *gin.Context) { + var req struct { + SourceID uint `json:"sourceId"` + History []services.ChatMessage `json:"history" binding:"required,min=1,max=40"` + Page string `json:"page"` + RangeMinutes int `json:"rangeMinutes"` + } + if !utils.BindAndValidate(c, &req) { + return + } + cfg := services.GetAIConfig() + var src *models.LogSource + if req.SourceID > 0 { + src = services.GetLogSource(req.SourceID) + } + rangeMinutes := req.RangeMinutes + if rangeMinutes <= 0 || rangeMinutes > 120 { + rangeMinutes = 15 + } + reply, err := services.RunAIChat(c.Request.Context(), cfg, src, c.GetUint("userId"), c.GetString("role") == "admin", req.History, req.Page, rangeMinutes) + if err != nil { + utils.Error(c, 400, err.Error()) + return + } + utils.Success(c, gin.H{"reply": reply}) +} diff --git a/backend/routers/router.go b/backend/routers/router.go index 5f1214e..a1406a5 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -131,6 +131,7 @@ func SetupRouter() *gin.Engine { ai.PUT("/config", middleware.AdminAuth(), aiCtrl.SaveConfig) ai.POST("/config/test", middleware.AdminAuth(), aiCtrl.TestConfig) ai.POST("/analyze", middleware.RequirePro(), aiCtrl.Analyze) + ai.POST("/chat", middleware.RequirePro(), aiCtrl.Chat) ai.GET("/runs", aiCtrl.ListRuns) ai.GET("/runs/:id", aiCtrl.GetRun) } diff --git a/backend/services/ai_provider.go b/backend/services/ai_provider.go index 4317140..aacce3d 100644 --- a/backend/services/ai_provider.go +++ b/backend/services/ai_provider.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "lokistack/repository" "net/http" "strings" "time" @@ -126,3 +127,70 @@ func TestAIConnection(cfg models.AIConfig) (string, error) { } return strings.TrimSpace(reply), nil } + +// ChatMessage 浮窗助手的多轮消息(user/assistant 交替) +type ChatMessage struct { + Role string `json:"role" binding:"required,oneof=user assistant"` + Content string `json:"content" binding:"required"` +} + +// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。 +// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。 +func RunAIChat(ctx context.Context, cfg models.AIConfig, src *models.LogSource, userID uint, admin bool, history []ChatMessage, page string, rangeMinutes int) (string, error) { + if !cfg.Enabled { + return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存") + } + if cfg.DailyTokenCap > 0 && AITokenUsedToday(userID) >= cfg.DailyTokenCap { + return "", fmt.Errorf("今日 AI 用量已达上限,明天再试或调整上限") + } + + system := "你是 LokiStack 日志平台的运维助手。回答简明、面向排查行动;不确定就说不确定。" + if src != nil && rangeMinutes > 0 { + end := time.Now() + from := end.Add(-time.Duration(rangeMinutes) * time.Minute) + if bundle, err := CollectAISignals(ctx, src, userID, admin, from, end, ""); err == nil { + system += "\n\n当前用户正在查看 " + page + " 页,近 " + fmt.Sprintf("%d", rangeMinutes) + " 分钟的日志概况:总量 " + + fmt.Sprintf("%d", bundle.TotalEntries) + " 条(估算)" + if len(bundle.Apps) > 0 { + system += ",Top 应用:" + for i, a := range bundle.Apps { + if i >= 3 { + break + } + system += fmt.Sprintf(" %s(%d)", a.App, a.Entries) + } + } + if len(bundle.Alerts) > 0 { + system += ";存在告警命中" + } + } + } + + msgs := []aiMessage{{Role: "system", Content: system}} + for _, m := range history { + msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content}) + } + // 脱敏:用户消息里的敏感串同样替换 + if cfg.RedactExtra != nil { + for i := range msgs { + msgs[i].Content = RedactTextOnly(msgs[i].Content, cfg.RedactExtra) + } + } + reply, promptTokens, completionTokens, err := aiChat(ctx, cfg, msgs) + if err != nil { + return "", err + } + // 计入每日配额:落一条 chat 类型用量记录 + db := repository.GetDB() + if db != nil { + db.Lock() + db.AIAnalysisRuns = append(db.AIAnalysisRuns, models.AIAnalysisRun{ + UserID: userID, Username: "chat", SourceID: 0, SourceName: page, + Status: "chat", PromptTokens: promptTokens, CompletionTokens: completionTokens, + CreatedAt: time.Now(), + }) + db.RequestSave() + db.Unlock() + } + return reply, nil +} diff --git a/frontend/src/api/ai/index.ts b/frontend/src/api/ai/index.ts index 562a918..71be0d2 100644 --- a/frontend/src/api/ai/index.ts +++ b/frontend/src/api/ai/index.ts @@ -91,4 +91,7 @@ export const aiApi = { `/ai/runs?page=${page}&pageSize=${pageSize}`, ), run: (id: number) => http.get(`/ai/runs/${id}`), + + chat: (data: { sourceId?: number; history: Array<{ role: 'user' | 'assistant'; content: string }>; page?: string; rangeMinutes?: number }) => + http.post<{ reply: string }>('/ai/chat', data), } diff --git a/frontend/src/components/AiAssistant.tsx b/frontend/src/components/AiAssistant.tsx new file mode 100644 index 0000000..5326e69 --- /dev/null +++ b/frontend/src/components/AiAssistant.tsx @@ -0,0 +1,124 @@ +import { memo, useEffect, useRef, useState } from 'react' +import { useLocation } from 'react-router' +import { useTranslation } from 'react-i18next' +import { Loader2, Send, Sparkles, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { aiApi } from '@/api/ai' +import { getUser } from '@/lib/auth-storage' +import { useLicense } from '@/hooks/useLicense' + +interface Msg { role: 'user' | 'assistant'; content: string } + +/** + * 全站 AI 助手浮窗(参考 FlowStack 右下角浮空设计): + * 仅管理员 + Pro 生效时渲染;对话自动携带当前页面路径作为上下文, + * 后端按需注入当前数据源的日志摘要,让"现在这个报错是什么"有数据依据。 + */ +export const AiAssistant = memo(function AiAssistant() { + const { t } = useTranslation('aiops') + const location = useLocation() + const { pro } = useLicense() + const isAdmin = getUser()?.role === 'admin' + const [open, setOpen] = useState(false) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [sending, setSending] = useState(false) + const listRef = useRef(null) + + useEffect(() => { + if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight + }, [messages, sending]) + + if (!isAdmin || !pro) return null + + const send = async () => { + const text = input.trim() + if (!text || sending) return + const next: Msg[] = [...messages, { role: 'user', content: text }] + setMessages(next) + setInput('') + setSending(true) + try { + const res = await aiApi.chat({ + history: next.slice(-20), + page: location.pathname, + rangeMinutes: 15, + }) + setMessages(current => [...current, { role: 'assistant', content: res.reply }]) + } catch (err) { + setMessages(current => [...current, { role: 'assistant', content: err instanceof Error ? err.message : t('assistant.error') }]) + } finally { + setSending(false) + } + } + + return ( + <> + + {open && ( +
+
+ + + {t('assistant.title')} + + +
+
+ {messages.length === 0 && ( +

{t('assistant.emptyHint')}

+ )} + {messages.map((m, i) => ( +
+
+ {m.content} +
+
+ ))} + {sending && ( +
+
+ + {t('assistant.thinking')} +
+
+ )} +
+
+