Files
opsx 550d8f4967
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
refactor(ai): 助手按 AI-AGENT 开发指南对齐(Ask 模式)
对照指南逐条评估后落地 4 项真实差距(MCP/Agent Loop/白名单等重型章节不适用,明确排除):
- 上下文预算(指南 07 TrimMessages):历史按 24k 字符预算从最旧裁剪,system 标注
  「更早的对话已省略」;抽 trimHistory 独立函数+单测(超预算丢最旧/小历史不裁)
- 配额超限诚实终态(指南 05.3/06 禁止伪装 completed):不再 400 报错,返回提示
  文案;前端渲染为 warning 提示条样式,对话上下文保留
- 回复脱敏(指南 08):assistant 回复同样过 RedactTextOnly,堵住模型复述样本
  带回敏感串的通道
- 退避重试(指南 05.2):429/5xx/网络错误指数退避 1s→2s 最多 2 次(计入总超时),
  鉴权/参数错不重试
2026-11-01 01:59:17 +08:00

272 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"lokistack/repository"
"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"`
ReasoningContent string `json:"reasoning_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))
log.Printf("[ai-chat] url=%s model=%s keyLen=%d status=%d body=%.300s", url, cfg.Model, len(cfg.APIKey), resp.StatusCode, string(raw))
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 为空)")
}
content := parsed.Choices[0].Message.Content
// DeepSeek 推理系模型(deepseek-reasoner / v4-pro 高推理档)会把过程写在
// reasoning_content,最终 content 在 MaxTokens 太小时可能为空 —— 回退使用推理内容
if strings.TrimSpace(content) == "" {
content = parsed.Choices[0].Message.ReasoningContent
}
return content, parsed.Usage.PromptTokens, parsed.Usage.CompletionTokens, nil
}
// AIConnectionTest 连通性测试结果:面向展示的结构化信息,不外发原始回复
type AIConnectionTest struct {
Model string `json:"model"`
LatencyMs int64 `json:"latencyMs"`
TokensUsed int `json:"tokensUsed"`
ReplyLength int `json:"replyLength"`
}
// TestAIConnection 连通性测试:发一句最小请求,验证 baseURL/key/model 三件套。
// 返回结构化结果(模型/耗时/token);模型原始回复不回传 —— 测试要的是"通没通、多快",不是看模型说了什么。
func TestAIConnection(cfg models.AIConfig) (*AIConnectionTest, error) {
cfg.MaxTokens = 32
cfg.TimeoutSec = 30
start := time.Now()
reply, promptTokens, completionTokens, err := aiChat(context.Background(), cfg, []aiMessage{
{Role: "user", Content: "只回复两个字:正常"},
})
if err != nil {
return nil, err
}
trimmed := strings.TrimSpace(reply)
if trimmed == "" {
return nil, fmt.Errorf("模型返回了空内容(检查 baseURL 是否为 OpenAI 兼容端点)")
}
return &AIConnectionTest{
Model: cfg.Model,
LatencyMs: time.Since(start).Milliseconds(),
TokensUsed: promptTokens + completionTokens,
ReplyLength: len([]rune(trimmed)),
}, nil
}
// ChatMessage 浮窗助手的多轮消息(user/assistant 交替)
type ChatMessage struct {
Role string `json:"role" binding:"required,oneof=user assistant"`
Content string `json:"content" binding:"required"`
}
// trimHistory 按字符预算从最旧开始裁剪对话历史(指南 07 TrimMessages);
// 返回保留的历史与是否发生了裁剪(调用方在 system 里标注省略)
func trimHistory(history []ChatMessage, budget int) ([]ChatMessage, bool) {
var kept []ChatMessage
total := 0
truncated := false
for i := len(history) - 1; i >= 0; i-- {
size := len([]rune(history[i].Content))
if total+size > budget && len(kept) > 0 {
truncated = true
break
}
kept = append([]ChatMessage{{Role: history[i].Role, Content: history[i].Content}}, kept...)
total += size
}
return kept, truncated
}
// isRetryableAIError 网络抖动/限流/上游临时故障才值得重试;鉴权与参数错重试无意义
func isRetryableAIError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "429") ||
strings.Contains(msg, "500") || strings.Contains(msg, "502") ||
strings.Contains(msg, "503") || strings.Contains(msg, "504") ||
strings.Contains(msg, "connection refused") || strings.Contains(msg, "context deadline exceeded") ||
strings.Contains(msg, "EOF") || strings.Contains(msg, "timeout")
}
// RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。
// 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。
func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) {
if !cfg.Enabled {
return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存")
}
if cfg.DailyTokenCap > 0 && AITokenUsedToday(userID) >= cfg.DailyTokenCap {
// 指南 05.3:超限不伪装成功 —— 结构化 notice 而非报错,对话上下文在前端保留
return "\u2014\u2014 今日 AI 配额已用完(" + fmt.Sprintf("%d tokens),明天再试或在「AI 模型接入」调整上限 \u2014\u2014", cfg.DailyTokenCap), nil
}
// 提示词设计:身份固定(不猜底层模型)、风格收敛、话题回引导、上下文数据
system := `你是 LokiStack 日志平台内置的运维助手。
规则:
- 回答不超过 5 句话,优先给排查动作,其次给结论。
- 被问"你是什么模型/版本"时,只答一句:"我是 LokiStack 内置运维助手,底层模型由管理员在「智能运维 → 模型配置」中指定。"然后引导回日志排查。
- 只讨论日志、Loki、采集、告警与平台使用;无关话题(写代码、闲聊、其它产品)用一句礼貌拒绝并引导回排查。
- 引用数据时只使用下方"日志概况"中的数字,不得编造。`
// 页面上下文(如 /logs 刚执行的查询与样本日志):截断并统一脱敏后并入 system
if trimmed := strings.TrimSpace(pageContext); trimmed != "" {
if len([]rune(trimmed)) > 6000 {
trimmed = string([]rune(trimmed)[:6000])
}
system += "\n\n" + RedactTextOnly(trimmed, cfg.RedactExtra)
}
// 上下文预算(指南 07 TrimMessages):按字符预算从最旧开始丢弃,优先保留近期对话
trimmed, truncated := trimHistory(history, 24000)
if truncated {
system += "\n(更早的对话已省略)"
}
msgs := []aiMessage{{Role: "system", Content: system}}
for _, m := range trimmed {
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)
}
}
// 指南 05.2:429/5xx/网络错误指数退避重试(1s→2s,最多 2 次);鉴权/参数错不重试
var reply string
var promptTokens, completionTokens int
var err error
for attempt := 0; ; attempt++ {
reply, promptTokens, completionTokens, err = aiChat(ctx, cfg, msgs)
if err == nil || attempt >= 2 || !isRetryableAIError(err) {
break
}
backoff := time.Duration(1<<attempt) * time.Second
log.Printf("[ai-chat] 可重试错误(%v),%s 后第 %d 次重试", err, backoff, attempt+1)
select {
case <-time.After(backoff):
case <-ctx.Done():
return "", ctx.Err()
}
}
if err != nil {
return "", err
}
reply = RedactTextOnly(reply, cfg.RedactExtra)
// 计入每日配额:落一条 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
}