Files
InfoGenie/infogenie-backend-go/internal/service/ai.go
2026-04-01 22:03:57 +08:00

270 lines
7.6 KiB
Go
Raw 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 service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
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"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
// loadAIConfig 从数据库读取AI配置
func loadAIConfig(provider string) (apiKey, apiBase, defaultModel string, models []string, ok bool) {
if database.DB == nil {
return "", "", "", nil, false
}
var config model.AIConfig
if err := database.DB.Where("provider = ? AND is_enabled = ?", provider, true).First(&config).Error; err != nil {
return "", "", "", nil, false
}
// 解析models JSON
var modelList []string
if config.Models != "" {
if err := json.Unmarshal([]byte(config.Models), &modelList); err != nil {
// 如果解析失败,返回空的模型列表
modelList = []string{}
}
}
return config.APIKey, config.APIBase, config.DefaultModel, modelList, true
}
// loadRuntimeDeepSeek 读取管理员在后台配置的 DeepSeek 兼容接口OpenAI 格式),优先于 ai_config.json
func loadRuntimeDeepSeek() (apiBase, apiKey, defModel string, ok bool) {
if database.DB == nil {
return "", "", "", false
}
var row model.SiteAIRuntime
if err := database.DB.First(&row, 1).Error; err != nil {
return "", "", "", false
}
base := strings.TrimSpace(row.APIBase)
key := strings.TrimSpace(row.APIKey)
dm := strings.TrimSpace(row.DefaultModel)
if base != "" && key != "" {
return base, key, dm, true
}
return "", "", "", false
}
// openDeepSeekChatURL 解析 DeepSeek 兼容 /chat/completions 的完整 URL、密钥与最终落库模型名
func openDeepSeekChatURL(model string) (fullURL, apiKey, resolvedModel string, err error) {
if base, key, defModel, ok := loadRuntimeDeepSeek(); ok {
if model == "" {
model = defModel
}
if model == "" {
model = "deepseek-chat"
}
return strings.TrimSuffix(base, "/") + "/chat/completions", key, model, nil
}
apiKey, apiBase, defaultModel, models, ok := loadAIConfig("deepseek")
if !ok {
return "", "", "", fmt.Errorf("DeepSeek配置未设置请在管理员后台配置API Key和Base URL")
}
if model == "" {
model = defaultModel
}
if model == "" {
model = "deepseek-chat"
}
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0]
}
}
return strings.TrimSuffix(apiBase, "/") + "/chat/completions", apiKey, model, nil
}
func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string, error) {
urlStr, key, m, err := openDeepSeekChatURL(model)
if err != nil {
return "", err
}
return callOpenAICompatible(urlStr, key, m, messages, maxRetries, 90*time.Second)
}
// openKimiChatURL 解析 Kimi /v1/chat/completions
func openKimiChatURL(model string) (fullURL, apiKey, resolvedModel string, err error) {
apiKey, apiBase, defaultModel, models, ok := loadAIConfig("kimi")
if !ok {
return "", "", "", fmt.Errorf("Kimi配置未设置请在管理员后台配置API Key和Base URL")
}
if model == "" {
model = defaultModel
}
if model == "" {
model = "kimi-k2-0905-preview"
}
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0]
}
}
return strings.TrimSuffix(apiBase, "/") + "/v1/chat/completions", apiKey, model, nil
}
func CallKimi(messages []ChatMessage, model string) (string, error) {
urlStr, key, m, err := openKimiChatURL(model)
if err != nil {
return "", err
}
return callOpenAICompatible(urlStr, key, m, messages, 1, 30*time.Second)
}
func callOpenAICompatible(url, apiKey, model string, messages []ChatMessage, maxRetries int, timeout time.Duration) (string, error) {
reqBody := chatRequest{
Model: model,
Messages: messages,
Temperature: 0.7,
MaxTokens: 2000,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
client := &http.Client{Timeout: timeout}
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
lastErr = err
if attempt < maxRetries-1 {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(backoff)
continue
}
return "", fmt.Errorf("API调用异常已重试%d次: %w", maxRetries, err)
}
respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == 200 {
var result chatResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("解析响应失败: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("AI未返回有效内容")
}
return result.Choices[0].Message.Content, nil
}
lastErr = fmt.Errorf("API调用失败: %d - %s", resp.StatusCode, string(respBody))
if attempt < maxRetries-1 {
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(backoff)
}
}
return "", lastErr
}
func CallAI(provider, model string, messages []ChatMessage) (string, error) {
switch provider {
case "deepseek":
return CallDeepSeek(messages, model, 3)
case "kimi":
return CallKimi(messages, model)
default:
return "", fmt.Errorf("不支持的AI提供商: %s目前支持的提供商: deepseek, kimi", provider)
}
}
// OpenAIChatStream 向上游发起 stream:true 的请求;返回的 ReadCloser 需由调用方 Close。statusCode 非 200 时 body 已读完并关闭rc 为 nil。
func OpenAIChatStream(ctx context.Context, provider, model string, messages []ChatMessage) (rc io.ReadCloser, statusCode int, err error) {
var urlStr, apiKey, m string
switch provider {
case "deepseek":
urlStr, apiKey, m, err = openDeepSeekChatURL(model)
case "kimi":
urlStr, apiKey, m, err = openKimiChatURL(model)
default:
return nil, 0, fmt.Errorf("不支持的AI提供商: %s", provider)
}
if err != nil {
return nil, 0, err
}
streamBody := map[string]interface{}{
"model": m,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2000,
"stream": true,
}
bodyBytes, jerr := json.Marshal(streamBody)
if jerr != nil {
return nil, 0, fmt.Errorf("序列化请求失败: %w", jerr)
}
req, rerr := http.NewRequestWithContext(ctx, "POST", urlStr, bytes.NewReader(bodyBytes))
if rerr != nil {
return nil, 0, rerr
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
client := &http.Client{}
resp, derr := client.Do(req)
if derr != nil {
return nil, 0, derr
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, resp.StatusCode, fmt.Errorf("%s", strings.TrimSpace(string(b)))
}
return resp.Body, http.StatusOK, nil
}