init: initial commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user