Initialize blog scaffold
Add the CLI, site, and sample content so the project can run locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d2628b318
commit
b78f4b39c9
40 changed files with 9140 additions and 0 deletions
183
backend/internal/ai/deepseek.go
Normal file
183
backend/internal/ai/deepseek.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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 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"`
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue