osaet/backend/internal/ai/deepseek.go
yarnom f0b50d13ea feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
2026-06-01 15:48:04 +08:00

288 lines
7.3 KiB
Go

package ai
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
const (
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
DefaultDeepSeekModel = "deepseek-v4-pro"
)
type Config struct {
APIKey string
BaseURL string
Model string
}
type LocalConfig struct {
URL string
Model string
Temperature float64
TopP float64
NumPredict int
}
type deepSeekMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type deepSeekChatRequest struct {
Model string `json:"model"`
Messages []deepSeekMessage `json:"messages"`
Thinking map[string]string `json:"thinking,omitempty"`
ResponseFormat map[string]string `json:"response_format,omitempty"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type deepSeekChatResponse struct {
Choices []struct {
FinishReason string `json:"finish_reason"`
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
type slugResponse struct {
Slug string `json:"slug"`
Alternatives []string `json:"alternatives"`
}
type localGenerateRequest struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Options localOptions `json:"options"`
Prompt string `json:"prompt"`
}
type localOptions struct {
Temperature float64 `json:"temperature"`
TopP float64 `json:"top_p"`
NumPredict int `json:"num_predict"`
}
type localGenerateResponse struct {
Response string `json:"response"`
Error string `json:"error,omitempty"`
}
func GenerateSlug(ctx context.Context, config Config, title string, summary string) (string, error) {
apiKey := strings.TrimSpace(config.APIKey)
if apiKey == "" {
return "", errors.New("DeepSeek API key is empty")
}
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL == "" {
baseURL = DefaultDeepSeekBaseURL
}
model := strings.TrimSpace(config.Model)
if model == "" {
model = DefaultDeepSeekModel
}
userPrompt := fmt.Sprintf("Title: %s\nSummary: %s", title, summary)
payload := deepSeekChatRequest{
Model: model,
Messages: []deepSeekMessage{
{
Role: "system",
Content: `You generate concise English URL slugs for blog posts.
Return JSON only.
Rules:
- lowercase English
- use hyphen separators
- only a-z, 0-9, and hyphen
- translate non-English titles by meaning into natural English
- do not use pinyin, transliteration, romanization, or pronunciation-based spellings
- no leading or trailing hyphen
- max 80 characters
- preserve important technical terms
- do not include explanations
Examples:
- title "喜欢你" -> {"slug":"like-you","alternatives":["i-like-you","loving-you"]}
- title "用 Go 和 Astro 构建个人博客" -> {"slug":"building-a-personal-blog-with-go-and-astro","alternatives":["go-astro-personal-blog"]}
JSON format: {"slug":"example-slug","alternatives":["another-slug"]}`,
},
{Role: "user", Content: userPrompt},
},
Thinking: map[string]string{"type": "disabled"},
ResponseFormat: map[string]string{"type": "json_object"},
Temperature: 0.2,
MaxTokens: 300,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("DeepSeek API returned %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
}
var chatResp deepSeekChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", err
}
if chatResp.Error != nil {
return "", fmt.Errorf("DeepSeek API error: %s", chatResp.Error.Message)
}
if len(chatResp.Choices) == 0 {
return "", errors.New("DeepSeek API returned no choices")
}
choice := chatResp.Choices[0]
content := strings.TrimSpace(choice.Message.Content)
if content == "" {
return "", fmt.Errorf("DeepSeek returned empty content, finish_reason=%q", choice.FinishReason)
}
var slugResp slugResponse
if err := json.Unmarshal([]byte(content), &slugResp); err != nil {
return "", fmt.Errorf("failed to parse DeepSeek slug JSON: %w", err)
}
slug := sanitizeSlug(slugResp.Slug)
if slug == "" {
for _, alternative := range slugResp.Alternatives {
slug = sanitizeSlug(alternative)
if slug != "" {
break
}
}
}
if slug == "" {
return "", errors.New("DeepSeek returned an empty slug")
}
return slug, nil
}
func GenerateLocalSlug(ctx context.Context, config LocalConfig, title string, summary string) (string, error) {
url := strings.TrimSpace(config.URL)
if url == "" {
return "", errors.New("local LLM URL is empty")
}
model := strings.TrimSpace(config.Model)
if model == "" {
return "", errors.New("local LLM model is empty")
}
temperature := config.Temperature
if temperature == 0 {
temperature = 0.1
}
topP := config.TopP
if topP == 0 {
topP = 0.8
}
numPredict := config.NumPredict
if numPredict == 0 {
numPredict = 32
}
prompt := fmt.Sprintf(`Convert the following Chinese blog title into a concise English URL slug.
Output only the slug. Lowercase only. Use hyphens. Max 8 words.
Title: %s`, title)
if strings.TrimSpace(summary) != "" {
prompt += "\nSummary: " + summary
}
payload := localGenerateRequest{
Model: model,
Stream: false,
Options: localOptions{
Temperature: temperature,
TopP: topP,
NumPredict: numPredict,
},
Prompt: prompt,
}
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("local LLM returned %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
}
var generated localGenerateResponse
if err := json.Unmarshal(respBody, &generated); err != nil {
return "", err
}
if generated.Error != "" {
return "", fmt.Errorf("local LLM error: %s", generated.Error)
}
slug := sanitizeSlug(generated.Response)
if slug == "" {
return "", errors.New("local LLM returned an empty slug")
}
return slug, nil
}
func sanitizeSlug(slug string) string {
slug = strings.ToLower(strings.TrimSpace(slug))
re := regexp.MustCompile(`[^a-z0-9]+`)
slug = re.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
for strings.Contains(slug, "--") {
slug = strings.ReplaceAll(slug, "--", "-")
}
if len(slug) > 80 {
slug = strings.Trim(slug[:80], "-")
}
return slug
}