package service import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // AICallConfig holds the resolved configuration for a single AI API call. type AICallConfig struct { BaseURL string APIKey string Model string MaxTokens int SystemPrompt string } // ── Request / Response types (OpenAI-compatible format) ────────────────────── type dsMessage struct { Role string `json:"role"` Content string `json:"content"` } type dsRequest struct { Model string `json:"model"` Messages []dsMessage `json:"messages"` Stream bool `json:"stream"` MaxTokens int `json:"max_tokens,omitempty"` } type dsDelta struct { Content string `json:"content"` } type dsChoice struct { Delta dsDelta `json:"delta"` FinishReason string `json:"finish_reason"` } type dsSSELine struct { Choices []dsChoice `json:"choices"` } // ── Public API ──────────────────────────────────────────────────────────────── // CallDeepSeekStream sends a messages list to any OpenAI-compatible endpoint // defined in cfg, with stream:true. Pushes each delta content chunk to streamCh. func CallDeepSeekStream(ctx context.Context, cfg AICallConfig, messages []dsMessage, streamCh chan<- string) error { if cfg.APIKey == "" { return fmt.Errorf("API key 未配置,请在设置中填写或联系管理员") } payload := dsRequest{ Model: cfg.Model, Messages: messages, Stream: true, MaxTokens: cfg.MaxTokens, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal request: %w", err) } client := &http.Client{Timeout: 60 * time.Second} req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.BaseURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "Bearer "+cfg.APIKey) resp, err := client.Do(req) if err != nil { if ctx.Err() != nil { return ctx.Err() } return fmt.Errorf("http request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { errBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("upstream status %d: %s", resp.StatusCode, string(errBody)) } return parseDeepSeekSSE(ctx, resp.Body, streamCh) } // BuildRAGMessages constructs the OpenAI-compatible messages slice. // If customSystemPrompt is non-empty, it replaces the built-in RAG template. func BuildRAGMessages(knowledgeContext, userQuery, customSystemPrompt string) []dsMessage { var systemContent string if customSystemPrompt != "" { systemContent = customSystemPrompt if knowledgeContext != "" && knowledgeContext != "(无相关本地知识)" { systemContent += "\n\n以下是本地知识库中的相关内容供参考:\n---\n" + knowledgeContext + "\n---" } } else { systemContent = fmt.Sprintf( "你是一位专业的植物养护和客服顾问,擅长用温暖、自然、有亲和力的语气沟通。\n\n"+ "以下是来自本地知识库的相关内容,请优先参考:\n\n---\n%s\n---\n\n"+ "根据以上知识润色话术,直接输出内容,不加前缀或解释。", knowledgeContext, ) } return []dsMessage{ {Role: "system", Content: systemContent}, {Role: "user", Content: userQuery}, } } func parseDeepSeekSSE(ctx context.Context, body io.Reader, ch chan<- string) error { scanner := bufio.NewScanner(body) scanner.Buffer(make([]byte, 64*1024), 64*1024) for scanner.Scan() { if ctx.Err() != nil { return ctx.Err() } line := scanner.Text() if !strings.HasPrefix(line, "data:") { continue } data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) if data == "[DONE]" { break } var event dsSSELine if err := json.Unmarshal([]byte(data), &event); err != nil { continue } if len(event.Choices) > 0 { if chunk := event.Choices[0].Delta.Content; chunk != "" { ch <- chunk } } } return scanner.Err() }