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.
288 lines
7.3 KiB
Go
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
|
|
}
|