package ai import ( "encoding/json" "errors" "fmt" "fonchain-fiee/pkg/common/qwen" "fonchain-fiee/pkg/service" "strings" "github.com/gin-gonic/gin" ) // VideoVLRequest 视频/图片理解请求参数 type VideoVLRequest struct { Videos []string `json:"videos"` // 视频URL列表 Images []string `json:"images"` // 图片URL列表 Text string `json:"text"` // 可选的文本提示 Model string `json:"model"` // 可选的模型名称,默认使用 qwen3-vl-plus } // AIVideoVL AI理解视频/图片接口 func AIVideoVL(ctx *gin.Context) { var req VideoVLRequest if err := ctx.ShouldBindJSON(&req); err != nil { service.Error(ctx, errors.New("参数错误")) return } // 检查是否至少提供了视频或图片 if len(req.Videos) == 0 && len(req.Images) == 0 { service.Error(ctx, errors.New("至少需要提供一个视频或图片")) return } if len(req.Videos) > 1 { service.Error(ctx, errors.New("当前只能选一个视频")) return } Prompt := "请你详细描述视频和图片中的内容分别是什么" // 调用VL函数进行AI理解 result, err := qwen.VL(req.Videos, req.Images, Prompt, req.Model) if err != nil { // 检查是否是文件下载超时错误(内容过大) errMsg := err.Error() if contains(errMsg, "Download multimodal file timed out") || contains(errMsg, "timed out") { service.Error(ctx, errors.New("内容过大,请重新选择")) } else { service.Error(ctx, errors.New("ai分析帖子内容失败")) } return } // 返回AI返回的数据 service.Success(ctx, result) } // contains 检查字符串是否包含子字符串(不区分大小写) func contains(s, substr string) bool { return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) } // CompetitorReportRequest 竞品报告请求参数 type CompetitorReportRequest struct { Videos []string `json:"videos"` // 视频URL列表 Images []string `json:"images"` // 图片URL列表 TextPrompt string `json:"textPrompt"` // 竞品报告要求文本 ImagePrompt string `json:"imagePrompt"` // 图片URL Model string `json:"model"` // 可选的模型名称,默认使用 qwen3-vl-plus } // CompetitorReportData 竞品报告数据(用于返回给前端) type CompetitorReportData struct { HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"` DataPerformance DataPerformanceData `json:"data_performance_analysis"` OverallSummary string `json:"overall_summary_and_optimization"` } type HighlightAnalysisData struct { Summary string `json:"summary"` Points PointsData `json:"points"` } type PointsData struct { Theme string `json:"theme"` Narrative string `json:"narrative"` Content string `json:"content"` Copywriting string `json:"copywriting"` Data string `json:"data"` Music string `json:"music,omitempty"` } type DataPerformanceData struct { Views string `json:"views"` Completion string `json:"completion_rate,omitempty"` Engagement string `json:"engagement"` } // CompetitorReportResponse 竞品报告响应数据 type CompetitorReportResponse struct { ImageURL string `json:"image_url,omitempty"` // 生成的图片URL(1024*1024),非必须返回 Text string `json:"text,omitempty"` // 竞品报告文本内容,非必须返回 JsonData *CompetitorReportData `json:"json_data,omitempty"` // 竞品报告JSON数据 } // CompetitorReportJSON AI返回的JSON结构 type CompetitorReportJSON struct { HighlightAnalysis HighlightAnalysis `json:"highlight_analysis"` DataPerformance DataPerformance `json:"data_performance_analysis"` OverallSummary string `json:"overall_summary_and_optimization"` } type HighlightAnalysis struct { Summary string `json:"summary"` Points Points `json:"points"` } type Points struct { Theme string `json:"theme"` Narrative string `json:"narrative"` Content string `json:"content"` Copywriting string `json:"copywriting"` Data string `json:"data"` Music string `json:"music,omitempty"` } type DataPerformance struct { Views string `json:"views"` Completion string `json:"completion_rate,omitempty"` Engagement string `json:"engagement"` } // convertJSONToText 将 JSON 转换为纯文本格式 func convertJSONToText(data CompetitorReportJSON, isVideo bool) string { var sb strings.Builder // 一、亮点表现分析 sb.WriteString("一、亮点表现分析\n") sb.WriteString(data.HighlightAnalysis.Summary) sb.WriteString("\n\n") sb.WriteString("1. 标题亮点:") sb.WriteString(data.HighlightAnalysis.Points.Theme) sb.WriteString("\n") sb.WriteString("2. 题材亮点:") sb.WriteString(data.HighlightAnalysis.Points.Narrative) sb.WriteString("\n") sb.WriteString("3. 内容亮点:") sb.WriteString(data.HighlightAnalysis.Points.Content) sb.WriteString("\n") sb.WriteString("4. 文案亮点:") sb.WriteString(data.HighlightAnalysis.Points.Copywriting) sb.WriteString("\n") sb.WriteString("5. 数据亮点:") sb.WriteString(data.HighlightAnalysis.Points.Data) sb.WriteString("\n") if isVideo && data.HighlightAnalysis.Points.Music != "" { sb.WriteString("6. 配乐亮点:") sb.WriteString(data.HighlightAnalysis.Points.Music) sb.WriteString("\n") } // 二、数据表现分析 sb.WriteString("\n二、数据表现分析\n") sb.WriteString("1. 浏览量表现:") sb.WriteString(data.DataPerformance.Views) sb.WriteString("\n") if isVideo && data.DataPerformance.Completion != "" { sb.WriteString("2. 完播率表现:") sb.WriteString(data.DataPerformance.Completion) sb.WriteString("\n") sb.WriteString("3. 点赞/分享/评论表现:") } else { sb.WriteString("2. 点赞/分享/评论表现:") } sb.WriteString(data.DataPerformance.Engagement) sb.WriteString("\n") // 三、整体总结及可优化建议 sb.WriteString("\n三、整体总结及可优化建议\n") sb.WriteString(data.OverallSummary) return sb.String() } // convertJSONToTextFromData 将 JSON 转换为纯文本格式(使用新的 CompetitorReportData 结构) func convertJSONToTextFromData(data CompetitorReportData, isVideo bool) string { var sb strings.Builder // 一、亮点表现分析 sb.WriteString("一、亮点表现分析\n") sb.WriteString(data.HighlightAnalysis.Summary) sb.WriteString("\n\n") sb.WriteString("1. 标题亮点:") sb.WriteString(data.HighlightAnalysis.Points.Theme) sb.WriteString("\n") sb.WriteString("2. 题材亮点:") sb.WriteString(data.HighlightAnalysis.Points.Narrative) sb.WriteString("\n") sb.WriteString("3. 内容亮点:") sb.WriteString(data.HighlightAnalysis.Points.Content) sb.WriteString("\n") sb.WriteString("4. 文案亮点:") sb.WriteString(data.HighlightAnalysis.Points.Copywriting) sb.WriteString("\n") sb.WriteString("5. 数据亮点:") sb.WriteString(data.HighlightAnalysis.Points.Data) sb.WriteString("\n") if isVideo && data.HighlightAnalysis.Points.Music != "" { sb.WriteString("6. 配乐亮点:") sb.WriteString(data.HighlightAnalysis.Points.Music) sb.WriteString("\n") } // 二、数据表现分析 sb.WriteString("\n二、数据表现分析\n") sb.WriteString("1. 浏览量表现:") sb.WriteString(data.DataPerformance.Views) sb.WriteString("\n") if isVideo && data.DataPerformance.Completion != "" { sb.WriteString("2. 完播率表现:") sb.WriteString(data.DataPerformance.Completion) sb.WriteString("\n") sb.WriteString("3. 点赞/分享/评论表现:") } else { sb.WriteString("2. 点赞/分享/评论表现:") } sb.WriteString(data.DataPerformance.Engagement) sb.WriteString("\n") // 三、整体总结及可优化建议 sb.WriteString("\n三、整体总结及可优化建议\n") sb.WriteString(data.OverallSummary) return sb.String() } // AICompetitorReport 生成竞品报告接口 func AICompetitorReport(ctx *gin.Context) { var req CompetitorReportRequest if err := ctx.ShouldBindJSON(&req); err != nil { service.Error(ctx, errors.New("参数错误")) return } if req.TextPrompt == "" && req.ImagePrompt == "" { service.Error(ctx, errors.New("文本和图片提示词不能同时为空")) return } // 检查是否至少提供了视频或图片 if len(req.Videos) == 0 && len(req.Images) == 0 { service.Error(ctx, errors.New("至少需要提供一个视频或图片")) return } if len(req.Videos) > 1 { service.Error(ctx, errors.New("当前只能选一个视频")) return } // 第一步:调用AI理解视频/图片内容 vlPrompt := "请你详细描述这些视频或者这些图片中的内容分别是什么,请详细描述,不要遗漏任何细节" vlResult, err := qwen.VL(req.Videos, req.Images, vlPrompt, req.Model) if err != nil { // 检查是否是文件下载超时错误(内容过大) errMsg := err.Error() if contains(errMsg, "Download multimodal file timed out") || contains(errMsg, "timed out") { service.Error(ctx, errors.New("内容过大,请重新选择")) } else { service.Error(ctx, fmt.Errorf("AI理解视频图片失败: %v", err)) } return } // 获取理解后的内容 if len(vlResult.Choices) == 0 { service.Error(ctx, errors.New("AI理解返回结果为空")) return } vlContent := vlResult.Choices[0].Message.Content // 定义协程结果结构 type textResult struct { text string err error } type imageResult struct { imageURL string err error } // 根据 TextPrompt 和 ImagePrompt 是否为空决定启动哪些协程 needText := req.TextPrompt != "" needImage := req.ImagePrompt != "" var textChan chan textResult var imageChan chan imageResult // 如果需要生成文本,启动文本生成协程 if needText { textChan = make(chan textResult, 1) go func() { // 根据是否有视频来判断作品类型 isVideo := len(req.Videos) > 0 // 构建文本生成提示词:理解内容 + 用户要求(JSON格式) // 重要:必须明确要求使用英文标点符号,确保返回的JSON符合规范 // 重要:必须基于内容给出分析性回复,即使没有提供具体数据 var textPrompt string if isVideo { textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式,不要输出任何其他内容。输出必须以 { 开头并以 } 结束。 重要提示: 1. 所有字符串值必须使用英文标点符号,包括英文逗号, 英文句号. 英文冒号: 英文引号" 等。禁止使用中文标点符号。 2. 即使没有提供具体数据,也要基于视频和图片内容给出分析性回复。禁止回复"未提供数据"、"暂无数据"等类似内容,而应该根据内容分析数据表现(如根据时长分析完播率潜力、根据内容质量分析互动潜力等)。 基于以下视频和图片的内容描述: %s 用户要求(仅作为内容参考,不会改变JSON结构): %s JSON结构是固定的,请将内容填充到对应的value中,禁止修改key,禁止添加额外字段,禁止输出任何说明文字: {"highlight_analysis":{"summary":"[78字以内的概述]","points":{"theme":"[标题亮点,最多60字]","narrative":"[题材亮点,最多60字]","content":"[内容亮点,最多60字]","copywriting":"[文案亮点,最多60字]","data":"[数据亮点,最多60字]","music":"[配乐亮点,仅视频,最多60字]"}},"data_performance_analysis":{"views":"[浏览量表现,最多60字]","completion_rate":"[完播率表现,仅视频,最多60字]","engagement":"[点赞/分享/评论表现,最多60字]"},"overall_summary_and_optimization":"[整体总结及可优化建议,最多300字]"}`, vlContent, req.TextPrompt) } else { textPrompt = fmt.Sprintf(`你必须严格输出以下JSON格式,不要输出任何其他内容。输出开头并以 }必须以 { 结束。 重要提示: 1. 所有字符串值必须使用英文标点符号,包括英文逗号, 英文句号. 英文冒号: 英文引号" 等。禁止使用中文标点符号。 2. 即使没有提供具体数据,也要基于视频和图片内容给出分析性回复。禁止回复"未提供数据"、"暂无数据"等类似内容,而应该根据内容分析数据表现(如根据内容质量分析互动潜力等)。 基于以下视频和图片的内容描述: %s 用户要求(仅作为内容参考,不会改变JSON结构): %s JSON结构是固定的,请将内容填充到对应的value中,禁止修改key,禁止添加额外字段,禁止输出任何说明文字: {"highlight_analysis":{"summary":"[78字以内的概述]","points":{"theme":"[标题亮点,最多60字]","narrative":"[题材亮点,最多60字]","content":"[内容亮点,最多60字]","copywriting":"[文案亮点,最多60字]","data":"[数据亮点,最多60字]"}},"data_performance_analysis":{"views":"[浏览量表现,最多60字]","engagement":"[点赞/分享/评论表现,最多60字]"},"overall_summary_and_optimization":"[整体总结及可优化建议,最多300字]"}`, vlContent, req.TextPrompt) } chatReq, err := buildChatRequest(textPrompt, nil) if err != nil { textChan <- textResult{err: err} return } chatResp, err := qwen.Chat(*chatReq) if err != nil { textChan <- textResult{err: err} return } if len(chatResp.Choices) == 0 { textChan <- textResult{err: errors.New("文本生成返回结果为空")} return } // 打印 AI 返回的原始内容(用于调试) aiText := chatResp.Choices[0].Message.Content fmt.Println("========== AI 返回的原始内容 ==========") fmt.Println(aiText) fmt.Println("=========================================") textChan <- textResult{text: aiText} }() } // 如果需要生成图片,启动图片生成协程 if needImage { imageChan = make(chan imageResult, 1) go func() { // 先请求聊天获取图片提示词 imagePromptText := fmt.Sprintf("基于以下视频和图片的内容描述:\n%s\n\n请根据以下要求生成竞品报告图片的提示词:\n%s\n\n重要提示:生成的图片内容中不要包含任何文字,仅仅是根据内容生成一张配图即可", vlContent, req.ImagePrompt) chatReq, err := buildChatRequest(imagePromptText, nil) if err != nil { imageChan <- imageResult{err: err} return } chatResp, err := qwen.Chat(*chatReq) if err != nil { imageChan <- imageResult{err: err} return } if len(chatResp.Choices) == 0 { imageChan <- imageResult{err: errors.New("图片提示词生成返回结果为空")} return } imagePrompt := chatResp.Choices[0].Message.Content // 生成图片(1024*1024),基于理解后的内容使用文生图 size := "1024*1024" resultTask, err := qwen.GenerateTextImage(imagePrompt, size) if err != nil { imageChan <- imageResult{err: err} return } if resultTask.Code != "" { imageChan <- imageResult{err: errors.New("文生图失败: " + resultTask.Message)} return } // 等待图片生成完成 result, err := qwen.ImgTaskResult(resultTask.Output.TaskID) if err != nil { imageChan <- imageResult{err: err} return } if result == nil || len(result.Output.Results) == 0 { imageChan <- imageResult{err: errors.New("图片生成失败")} return } // 返回第一张图片的URL imageChan <- imageResult{imageURL: result.Output.Results[0].URL} }() } // 等待所有启动的协程完成 var textRes textResult var imageRes imageResult // 根据实际启动的协程数量等待结果 if needText && needImage { // 两个协程都启动了,使用循环等待两个都完成 completed := 0 for completed < 2 { select { case textRes = <-textChan: completed++ case imageRes = <-imageChan: completed++ } } } else if needText { // 只启动文本生成协程 textRes = <-textChan } else if needImage { // 只启动图片生成协程 imageRes = <-imageChan } // 处理文本结果(如果生成了文本) if needText { if textRes.err != nil { service.Error(ctx, fmt.Errorf("生成竞品报告文本失败: %v", textRes.err)) return } } // 处理图片结果(如果生成了图片) if needImage { if imageRes.err != nil { service.Error(ctx, fmt.Errorf("生成竞品报告图片失败: %v", imageRes.err)) return } } // 返回结果(只返回实际生成的内容) result := CompetitorReportResponse{} if needText { // 将 JSON 解析为结构化数据 fmt.Println("========== 开始解析 JSON ==========") fmt.Println("原始内容是否以 { 开头:", strings.HasPrefix(strings.TrimSpace(textRes.text), "{")) fmt.Println("原始内容前100字符:", strings.TrimSpace(textRes.text)[:min(100, len(strings.TrimSpace(textRes.text)))]) var jsonData CompetitorReportData if err := json.Unmarshal([]byte(textRes.text), &jsonData); err != nil { // 如果解析失败,回退使用原始文本 fmt.Println("========== JSON 解析失败 ==========") fmt.Println("解析错误:", err) fmt.Println("===================================") result.Text = textRes.text } else { fmt.Println("========== JSON 解析成功 ==========") fmt.Println("Summary:", jsonData.HighlightAnalysis.Summary) fmt.Println("==================================") // 赋值结构体到 JsonData 中 result.JsonData = &jsonData result.Text = convertJSONToTextFromData(jsonData, len(req.Videos) > 0) } } if needImage { result.ImageURL = imageRes.imageURL } service.Success(ctx, result) }