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 }