package handler import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "soul-api/internal/config" "soul-api/internal/database" "github.com/gin-gonic/gin" ) const aiArticleSystemPrompt = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。 写作要求: - 标题:精炼有力,≤30字,有吸引力,点明核心价值 - 正文:600-1000字,有真实故事或案例,有观点洞察,语气自然真诚 - 风格:像朋友分享经验,而非说教;有画面感,接地气 - 不要使用"首先、其次、最后"等模板化结构词 严格输出 JSON,不要有任何额外文字: {"title":"文章标题","content":"文章正文"}` // MiniprogramSuperArticleGenerate POST /api/miniprogram/super/articles/generate // 根据描述 + 图片 URL,调用 AI 生成文章草稿(仅超级个体可用)。 func MiniprogramSuperArticleGenerate(c *gin.Context) { var req struct { UserID string `json:"userId"` Description string `json:"description"` ImageURLs []string `json:"imageUrls"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"}) return } req.UserID = strings.TrimSpace(req.UserID) req.Description = strings.TrimSpace(req.Description) if req.UserID == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 userId"}) return } if req.Description == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "请填写描述内容"}) return } db := database.DB() if _, ok := miniprogramSuperIndividualPerson(db, req.UserID); !ok { c.JSON(http.StatusOK, gin.H{"success": false, "error": "仅超级个体可使用 AI 生成", "forbidden": true}) return } cfg := config.Get() if cfg == nil || strings.TrimSpace(cfg.OpenAIAPIKey) == "" { c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 功能暂未开放,请联系管理员配置"}) return } // 过滤空 URL var validImageURLs []string for _, u := range req.ImageURLs { if u = strings.TrimSpace(u); u != "" { validImageURLs = append(validImageURLs, u) } } title, content, err := aiGenerateArticle(cfg, req.Description, validImageURLs) if err != nil { c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 生成失败:" + err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"title": title, "content": content}}) } // aiGenerateArticle 调用 OpenAI 兼容 API 生成文章标题和正文。 // 若有图片 URL 且模型支持视觉,以多模态格式传入;否则退化为纯文本。 func aiGenerateArticle(cfg *config.Config, description string, imageURLs []string) (title, content string, err error) { apiKey := strings.TrimSpace(cfg.OpenAIAPIKey) baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.OpenAIBaseURL), "/") if baseURL == "" { baseURL = "https://api.openai.com/v1" } model := strings.TrimSpace(cfg.OpenAIModel) if model == "" { model = "gpt-4o-mini" } // 判断模型是否支持视觉(OpenAI gpt-4 系列 / claude-3 系列) supportsVision := len(imageURLs) > 0 && ( strings.Contains(model, "gpt-4") || strings.Contains(model, "claude-3") || strings.Contains(model, "vision")) var userContent interface{} if supportsVision { parts := []map[string]interface{}{ {"type": "text", "text": buildUserPrompt(description, imageURLs, true)}, } for _, u := range imageURLs { parts = append(parts, map[string]interface{}{ "type": "image_url", "image_url": map[string]string{"url": u, "detail": "low"}, }) } userContent = parts } else { userContent = buildUserPrompt(description, imageURLs, false) } reqBody, _ := json.Marshal(map[string]interface{}{ "model": model, "messages": []map[string]interface{}{ {"role": "system", "content": aiArticleSystemPrompt}, {"role": "user", "content": userContent}, }, "temperature": 0.8, "max_tokens": 2000, }) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/chat/completions", bytes.NewReader(reqBody)) if err != nil { return "", "", fmt.Errorf("构建请求失败: %v", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+apiKey) resp, err := http.DefaultClient.Do(httpReq) if err != nil { return "", "", fmt.Errorf("请求 AI 服务失败: %v", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var apiResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(body, &apiResp); err != nil { return "", "", fmt.Errorf("解析 AI 响应失败") } if apiResp.Error != nil { return "", "", fmt.Errorf("%s", apiResp.Error.Message) } if len(apiResp.Choices) == 0 || apiResp.Choices[0].Message.Content == "" { return "", "", fmt.Errorf("AI 无返回内容") } return parseArticleJSON(apiResp.Choices[0].Message.Content) } func buildUserPrompt(description string, imageURLs []string, visionMode bool) string { var sb strings.Builder sb.WriteString("根据以下素材,创作一篇精彩的文章:\n\n") sb.WriteString("用户描述:") sb.WriteString(description) if len(imageURLs) > 0 { if visionMode { sb.WriteString(fmt.Sprintf("\n\n(附%d张图片,请结合图片内容写作)", len(imageURLs))) } else { sb.WriteString(fmt.Sprintf("\n\n(用户上传了%d张图片作为参考素材,请在文章中适当体现图片场景)", len(imageURLs))) } } sb.WriteString("\n\n请严格输出 JSON 格式:{\"title\":\"标题\",\"content\":\"正文\"}") return sb.String() } func parseArticleJSON(raw string) (title, content string, err error) { raw = strings.TrimSpace(raw) // 去掉 markdown 代码块包裹 if idx := strings.Index(raw, "{"); idx >= 0 { if end := strings.LastIndex(raw, "}"); end > idx { raw = raw[idx : end+1] } } var article struct { Title string `json:"title"` Content string `json:"content"` } if jsonErr := json.Unmarshal([]byte(raw), &article); jsonErr != nil { // 降级:第一行当标题,其余为正文 lines := strings.SplitN(raw, "\n", 2) if len(lines) >= 2 { return strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]), nil } return "", "", fmt.Errorf("解析 AI 输出失败,请重试") } title = strings.TrimSpace(article.Title) content = strings.TrimSpace(article.Content) if title == "" || content == "" { return "", "", fmt.Errorf("AI 生成内容不完整,请重试") } // 截断超限内容 if tr := []rune(title); len(tr) > 40 { title = string(tr[:40]) } if cr := []rune(content); len(cr) > 5000 { content = string(cr[:5000]) } return title, content, nil }