feat: v0.4.0 主题切换 + LLM 全面优化 + 登录会话 + 用户管理/LDAP

主题:
- 深色/浅色/跟随系统(useTheme:本地缓存先行+远端同步+matchMedia),
  侧边栏快捷切换,设置页三档即选即生效

LLM(参考 opsxdb/aiops):
- llm.Client.ChatStream 流式(SSE 增量解析,透传 reasoning_content,
  ctx 控制超时);POST /api/ai/chat/stream(平台实时数据注入降幻觉,
  未配 LLM 降级规则 JSON,指令解析 action 不变)
- test-llm 支持草稿直测(保存前验证,apiKey 空/掩码回退已存)
- 发布失败自动 AI 分析日志尾部(结论/根因/建议),发布记录与发布
  Tab 展示分析卡片
- 设置页 Provider 预设(DeepSeek/GLM/GLM编码/Qwen/Kimi/Ollama/自定义)
  自动填 endpoint+模型+获取 Key 直达链接

登录与会话:
- HMAC 签名 token(7 天,密钥派生自 encryption.key);
  authMiddleware 保护 /api/*(豁免登录/健康/SSE日志流/入站webhook)
- 登录页 /login + 未登录跳转 + 登出;401 自动清理跳登录

用户管理与 LDAP(借鉴 LokiStack):
- /users 页面:本地用户 CRUD、一次性密码、角色切换、来源徽章
  (本地·蓝 | LDAP导入·紫);LDAP 导入按钮仅在启用且配置齐备时显示
- LDAP 配置卡(未启用仅提示语)、测试连接草稿直测+可选测试账号
- go-ldap:管理 Bind/用户搜索(防注入)/用户 DN 直连验证;
  批量导入冲突策略(已LDAP跳过/本地同名转LDAP/新建);
  登录分流本地 argon2id 或 LDAP 目录验证 + 首登 JIT 建号
This commit is contained in:
Logan.Li committed 2026-10-27 12:58:21 +08:00
1 parent 555934cc64
commit 97193eb090
24 files changed
+2422 -98

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/llm"
"flowstack/internal/model"
)
// buildPlatformContext 平台实时数据注入(降低 AI 幻觉):流水线概况 + 最近运行
func buildPlatformContext() string {
var pipelines []model.Pipeline
database.DB.Find(&pipelines)
var runs []model.PipelineRun
database.DB.Order("started_at DESC").Limit(5).Find(&runs)
running := 0
for _, r := range runs {
if r.Status == "running" || r.Status == "pending" {
running++
}
}
var b strings.Builder
fmt.Fprintf(&b, "【平台实时数据】\n流水线总数: %d(运行中 %d)\n", len(pipelines), running)
if len(runs) > 0 {
b.WriteString("最近运行:\n")
for _, r := range runs {
fmt.Fprintf(&b, "- %s %s %s %s %s\n", r.PipelineName, r.RunID, r.Branch, r.Status, fmtDurMs(r.DurationMs))
}
}
return b.String()
}
func fmtDurMs(ms int64) string {
if ms <= 0 {
return "-"
}
return fmt.Sprintf("%ds", ms/1000)
}
// AiChatStream POST /api/ai/chat/stream —— 流式对话(SSE)
func AiChatStream(c *gin.Context) {
var body struct {
Messages []ChatMsg `json:"messages" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "messages 不能为空"})
return
}
var lastUser string
for i := len(body.Messages) - 1; i >= 0; i-- {
if body.Messages[i].Role == "user" {
lastUser = body.Messages[i].Content
break
}
}
if lastUser == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "没有用户消息"})
return
}
candidates := allCandidates()
names := make([]string, 0, len(candidates))
if len(candidates) > 200 {
candidates = candidates[:200]
}
for _, cd := range candidates {
names = append(names, cd.Name)
}
ruleRes := parseByRules(lastUser, candidates)
client := llmClientFromSettings()
if client == nil {
// 未配置 LLM:直接返回规则结果(不流式)
reply, action := ruleReply(ruleRes, candidates)
c.JSON(http.StatusOK, gin.H{"reply": reply, "viaAI": false, "action": action})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flush := func() {
if f, ok := c.Writer.(http.Flusher); ok {
f.Flush()
}
}
emit := func(event string, data any) {
b, _ := json.Marshal(data)
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, b)
flush()
}
system := fmt.Sprintf(`你是 FlowStack 发布系统的智能发布助手。用户会用自然语言描述要发布的服务。
%s
可发布的服务清单(流水线与 Jenkins Job 名称)如下:
%s
规则:
- 若用户意图是发布/构建服务:只输出 JSON(不要 markdown 代码块、不要多余文字):
{"reply":"一句中文确认语","jobs":["服务名1"],"branch":"分支或留空","env":"环境或留空"}
jobs 必须来自上面的清单按原文匹配;用户提到的服务不在清单时 jobs 留空并在 reply 中说明。
- 其他问题(如询问状态、概念解释):用简洁中文自然语言回答,禁止编造清单中不存在的服务或字段。`,
buildPlatformContext(), strings.Join(names, "\n"))
emit("start", gin.H{"viaAI": true})
ctx := c.Request.Context()
full, err := client.ChatStream(ctx, system, lastUser, func(d llm.StreamDelta) {
if d.Content != "" || d.Reasoning != "" {
emit("delta", gin.H{"content": d.Content, "reasoning": d.Reasoning})
}
})
if err != nil {
// LLM 失败:降级规则结果
reply, action := ruleReply(ruleRes, candidates)
emit("done", gin.H{"reply": reply + "\n(AI 调用失败已降级本地规则: " + err.Error() + ")", "viaAI": false, "action": action})
return
}
reply, action := parseLLMJson(full, candidates)
if action == nil && len(ruleRes.Jobs) > 0 {
// LLM 没识别出发布指令但规则识别到了,合并规则结果
_, action = ruleReply(ruleRes, candidates)
}
emit("done", gin.H{"reply": reply, "viaAI": true, "action": action})
}
func ruleReply(ruleRes ruleParseResult, candidates []Candidate) (string, *aiReleaseAction) {
if len(ruleRes.Jobs) == 0 {
return "未识别到要发布的服务。请直接告诉我服务名,例如:发布 cex-agent-server、cex-agent-api 到 pre 分支。", nil
}
jobs := make([]aiActionJob, 0, len(ruleRes.Jobs))
for _, name := range ruleRes.Jobs {
m := matchCandidate(name, candidates)
jobs = append(jobs, aiActionJob{Name: name, Matched: m != nil, Source: sourceOf(m)})
}
action := &aiReleaseAction{Type: "release", Jobs: jobs, Branch: ruleRes.Branch, Env: ruleRes.Env}
return fmt.Sprintf("识别到 %d 个服务%s,确认后开始发布。", len(jobs), branchSuffix(ruleRes.Branch)), action
}
// analyzeFailure 发布失败时用 LLM 分析日志尾部(opsx-loki AnalyzeJenkins 同款 prompt)
func analyzeFailure(run model.PipelineRun) {
client := llmClientFromSettings()
if client == nil {
return
}
var logs []model.LogEntry
database.DB.Where("run_id = ?", run.ID).Order("timestamp DESC").Limit(100).Find(&logs)
if len(logs) == 0 {
return
}
// 倒序取出后反转为正序
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
logs[i], logs[j] = logs[j], logs[i]
}
var b strings.Builder
for _, l := range logs {
b.WriteString(l.Message)
b.WriteString("\n")
}
system := `你是资深发布助手。根据流水线执行日志分析发布失败原因。用简洁中文输出,结构固定:
1. 结论:一句话说明失败原因
2. 失败阶段或关键步骤
3. 根因(如编译错误、依赖缺失、测试失败、镜像推送失败、部署超时)
4. 处理建议(1-3 条,可执行)
不要复述整段日志。`
user := fmt.Sprintf("流水线: %s\n分支: %s\n状态: %s\n\n日志尾部:\n%s", run.PipelineName, run.Branch, run.Status, b.String())
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
reply, err := client.Chat(ctx, system, user)
if err != nil {
return
}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("analysis", reply)
}