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

+107
View File
@@ -2,6 +2,7 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -41,6 +42,7 @@ type chatRequest struct {
Messages []chatMessage `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
}
type chatResponse struct {
@@ -111,3 +113,108 @@ func truncate(s string, n int) string {
}
return s[:n] + "..."
}
// StreamDelta 单个流式增量
type StreamDelta struct {
Content string // 正文增量
Reasoning string // 推理过程增量(DeepSeek R1 类模型)
Usage *Usage // 最后一个 chunk 可能带用量
}
// Usage token 用量
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type streamChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
Reasoning string `json:"reasoning_content"`
FinishReason string `json:"finish_reason"`
} `json:"delta"`
} `json:"choices"`
Usage *Usage `json:"usage"`
}
// ChatStream 流式对话:onDelta 逐段回调(正文/推理),返回完整正文。
// 流式请求不设整体超时,由 ctx 控制取消。
func (c *Client) ChatStream(ctx context.Context, system, user string, onDelta func(StreamDelta)) (string, error) {
if c.Endpoint == "" || c.APIKey == "" || c.Model == "" {
return "", fmt.Errorf("LLM 未配置")
}
payload := chatRequest{
Model: c.Model,
Messages: []chatMessage{
{Role: "system", Content: system},
{Role: "user", Content: user},
},
Temperature: 0.2,
MaxTokens: c.MaxTokens,
Stream: true,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
// 流式专用 client:Timeout 0,取消由 ctx 控制
resp, err := (&http.Client{}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
}
var full strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var chunk streamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Choices) == 0 {
if chunk.Usage != nil && onDelta != nil {
onDelta(StreamDelta{Usage: chunk.Usage})
}
continue
}
d := chunk.Choices[0].Delta
if d.Content != "" || d.Reasoning != "" {
full.WriteString(d.Content)
if onDelta != nil {
onDelta(StreamDelta{Content: d.Content, Reasoning: d.Reasoning})
}
}
}
if err := scanner.Err(); err != nil {
if ctx.Err() != nil {
return full.String(), nil // 客户端主动取消:返回已收到的内容
}
return full.String(), err
}
if full.Len() == 0 {
return "", fmt.Errorf("流式响应为空")
}
return full.String(), nil
}