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"` } // 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 { return "", fmt.Errorf("今日 AI 用量已达上限,明天再试或调整上限") } // 提示词设计:身份固定(不猜底层模型)、风格收敛、话题回引导、上下文数据 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) } 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 }