主题: - 深色/浅色/跟随系统(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 建号
221 lines
5.7 KiB
Go
221 lines
5.7 KiB
Go
// Package llm OpenAI 兼容 Chat 客户端(移植自 opsx-jenkins 生产实现)。
|
||
package llm
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type Client struct {
|
||
Endpoint string
|
||
APIKey string
|
||
Model string
|
||
MaxTokens int
|
||
}
|
||
|
||
func NewClient(endpoint, apiKey, model string, maxTokens int) *Client {
|
||
if maxTokens <= 0 {
|
||
maxTokens = 1200
|
||
}
|
||
return &Client{
|
||
Endpoint: strings.TrimRight(endpoint, "/"),
|
||
APIKey: apiKey,
|
||
Model: model,
|
||
MaxTokens: maxTokens,
|
||
}
|
||
}
|
||
|
||
type chatMessage struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
type chatRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []chatMessage `json:"messages"`
|
||
Temperature float64 `json:"temperature"`
|
||
MaxTokens int `json:"max_tokens"`
|
||
Stream bool `json:"stream,omitempty"`
|
||
}
|
||
|
||
type chatResponse struct {
|
||
Choices []struct {
|
||
Message struct {
|
||
Content string `json:"content"`
|
||
} `json:"message"`
|
||
} `json:"choices"`
|
||
Error *struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
|
||
// Chat 单轮对话;endpoint/apiKey 为空时返回错误由调用方降级
|
||
func (c *Client) Chat(ctx context.Context, system, user string) (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,
|
||
}
|
||
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)
|
||
|
||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var out chatResponse
|
||
if err := json.Unmarshal(data, &out); err != nil {
|
||
return "", fmt.Errorf("响应解析失败: %s", truncate(string(data), 200))
|
||
}
|
||
if out.Error != nil && out.Error.Message != "" {
|
||
return "", fmt.Errorf("LLM 错误: %s", out.Error.Message)
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||
}
|
||
if len(out.Choices) == 0 {
|
||
return "", fmt.Errorf("空响应")
|
||
}
|
||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
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
|
||
}
|