fonchain-fiee/pkg/service/ai/video_vl.go
2026-02-28 14:58:40 +08:00

459 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// CompetitorReportResponse 竞品报告响应数据
type CompetitorReportResponse struct {
ImageURL string `json:"image_url,omitempty"` // 生成的图片URL1024*1024非必须返回
Text string `json:"text,omitempty"` // 竞品报告文本内容,非必须返回
JSON string `json:"json,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()
}
// 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格式
var textPrompt string
if isVideo {
textPrompt = fmt.Sprintf(`基于以下视频和图片的内容描述:
%s
请根据以下要求生成竞品报告输出严格的JSON格式不要输出markdown格式不要输出其他无关内容。
我的要求是:
%s
请严格按照以下JSON模板输出注意不要修改key只填写value
{
"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字]"
}
重要约束:
1. 严禁输出 JSON 以外的任何字符包括换行说明、注释、Markdown
2. 若无法满足字数限制,请主动压缩内容,而不是省略字段`, vlContent, req.TextPrompt)
} else {
textPrompt = fmt.Sprintf(`基于以下视频和图片的内容描述:
%s
请根据以下要求生成竞品报告输出严格的JSON格式不要输出markdown格式不要输出其他无关内容。
我的要求是:
%s
请严格按照以下JSON模板输出注意不要修改key只填写value
{
"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字]"
}
重要约束:
1. 严禁输出 JSON 以外的任何字符包括换行说明、注释、Markdown
2. 若无法满足字数限制,请主动压缩内容,而不是省略字段`, 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 {
result.JSON = textRes.text // JSON 字段直接返回 AI 生成的 JSON
// 将 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 CompetitorReportJSON
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("==================================")
result.Text = convertJSONToText(jsonData, len(req.Videos) > 0)
}
}
if needImage {
result.ImageURL = imageRes.imageURL
}
service.Success(ctx, result)
}