feat: 新增创作作品管理模块及相关配置
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
package consts
|
||||
|
||||
// 数据库名称
|
||||
const (
|
||||
DbNameBlackDeacon = "black_deacon"
|
||||
)
|
||||
|
||||
// 数据库表名
|
||||
const (
|
||||
TableNameCreationInfo = "creation_info"
|
||||
|
||||
22
workflow/controller/creation_info_controller.go
Normal file
22
workflow/controller/creation_info_controller.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/model/dto"
|
||||
"ai-agent/workflow/service"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/beans"
|
||||
)
|
||||
|
||||
type creationInfo struct{}
|
||||
|
||||
var CreationInfo = new(creationInfo)
|
||||
|
||||
func (c *creationInfo) GraphInvoke(ctx context.Context, req *dto.CreationInput) (res *beans.ResponseEmpty, err error) {
|
||||
err = service.CreationInfoService.Creation(ctx, req)
|
||||
return
|
||||
}
|
||||
|
||||
func (c *creationInfo) List(ctx context.Context, req *dto.ListCreationInfoReq) (res *dto.ListCreationInfoRes, err error) {
|
||||
return service.CreationInfoService.List(ctx, req)
|
||||
}
|
||||
43
workflow/dao/creation_info_dao.go
Normal file
43
workflow/dao/creation_info_dao.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/consts"
|
||||
"ai-agent/workflow/model/dto"
|
||||
"ai-agent/workflow/model/entity"
|
||||
"context"
|
||||
|
||||
"gitea.com/red-future/common/db/gfdb"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var CreationInfoDao = &creationInfoDao{}
|
||||
|
||||
type creationInfoDao struct{}
|
||||
|
||||
func (d *creationInfoDao) List(ctx context.Context, req *dto.ListCreationInfoReq, fields ...string) (res []*entity.CreationInfo, total int, err error) {
|
||||
model := gfdb.DB(ctx, consts.DbNameBlackDeacon).Model(ctx, consts.TableNameCreationInfo).Fields(fields).OmitEmpty()
|
||||
model.Where(entity.CreationInfoCol.Creator, req.Creator)
|
||||
model.OrderDesc(entity.CreationInfoCol.CreatedAt)
|
||||
if req.Page != nil {
|
||||
model.Page(int(req.Page.PageNum), int(req.Page.PageSize))
|
||||
}
|
||||
r, total, err := model.AllAndCount(false)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = r.Structs(&res)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert 插入
|
||||
func (d *creationInfoDao) Insert(ctx context.Context, req *dto.Create) (id int64, err error) {
|
||||
e := &entity.CreationInfo{}
|
||||
if err = gconv.Struct(req, e); err != nil {
|
||||
return
|
||||
}
|
||||
r, err := gfdb.DB(ctx, consts.DbNameBlackDeacon).Model(ctx, consts.TableNameCreationInfo).Insert(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return r.LastInsertId()
|
||||
}
|
||||
115
workflow/model/dto/creation_info_dto.go
Normal file
115
workflow/model/dto/creation_info_dto.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"gitea.com/red-future/common/beans"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type CreationInput struct {
|
||||
g.Meta `path:"/creation" method:"post" tags:"创作作品管理" summary:"作品创作" dc:"作品创作"`
|
||||
|
||||
Mode string `json:"mode"`
|
||||
ContentType string `json:"content_type"`
|
||||
Theme string `json:"theme"`
|
||||
Title string `json:"title"`
|
||||
Style string `json:"style"`
|
||||
Count int `json:"count"`
|
||||
ImagePerPost int `json:"image_per_post"`
|
||||
ImageRatio string `json:"image_ratio"`
|
||||
Desc string `json:"desc"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ImageUploadItem struct {
|
||||
Title string `json:"title"` // 内容标题
|
||||
Index int `json:"index"` // 第几条
|
||||
ImageUrls []string `json:"image_urls"` // 上传成功的图片URL列表
|
||||
HtmlFileUrl string `json:"html_file_url"` // 上传成功的HTML文件URL(如有)
|
||||
Theme string `json:"theme"`
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
// CreationOutput 接口最终返回结构体(你原有dto,这里补充完整)
|
||||
type CreationOutput struct {
|
||||
SuccessCount int `json:"success_count"` // 成功条数
|
||||
Items []ImageUploadItem `json:"items"` // 所有上传成功的详情
|
||||
}
|
||||
|
||||
type Create struct {
|
||||
HtmlFileUrl string
|
||||
ImageUrls []string
|
||||
ContentType string
|
||||
Theme string
|
||||
Title string
|
||||
}
|
||||
|
||||
// UploadFileBytesReq 上传文件请求(字节流)
|
||||
type UploadFileBytesReq struct {
|
||||
FileName string `json:"fileName" dc:"文件名"`
|
||||
FileBytes []byte `json:"fileBytes" dc:"文件字节流"`
|
||||
FileStoreURL string `json:"fileStoreURL" dc:"文件存储URL"`
|
||||
}
|
||||
|
||||
type UploadFileBytesRes struct {
|
||||
FileURL string `json:"fileURL" dc:"上传地址"`
|
||||
FileSize int `json:"fileSize" dc:"文件大小"`
|
||||
FileName string `json:"fileName" dc:"文件名称"`
|
||||
FileFormat string `json:"fileFormat" dc:"文件格式"`
|
||||
FileAddressPrefix string `json:"fileAddressPrefix"`
|
||||
}
|
||||
|
||||
type ListCreationInfoReq struct {
|
||||
g.Meta `path:"/list" method:"get" tags:"创作作品管理" summary:"作品列表" dc:"作品列表"`
|
||||
|
||||
Page *beans.Page `json:"page"`
|
||||
Creator string `json:"creator"`
|
||||
}
|
||||
|
||||
type ListCreationInfoRes struct {
|
||||
List []*CreationInfoVO `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Tree []TimeNode
|
||||
ImgAddressPrefix string `json:"imgAddressPrefix"`
|
||||
}
|
||||
|
||||
type CreationInfoVO struct {
|
||||
Id int64 `json:"id,string" dc:"id"`
|
||||
HtmlFileUrl string `json:"htmlFileUrl"`
|
||||
ImageUrls []string `json:"imageUrls"`
|
||||
Theme string `json:"theme"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"`
|
||||
UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"`
|
||||
}
|
||||
|
||||
// 第一层:日期
|
||||
type TimeNode struct {
|
||||
CreatedDate string `json:"createdDate"`
|
||||
ContentTypes []ContentTypeNode `json:"contentTypes"`
|
||||
}
|
||||
|
||||
// 第二层:ContentType
|
||||
type ContentTypeNode struct {
|
||||
ContentType string `json:"contentType"`
|
||||
Themes []ThemeNode `json:"themes"`
|
||||
}
|
||||
|
||||
// 第三层:Theme
|
||||
type ThemeNode struct {
|
||||
Theme string `json:"theme"`
|
||||
Titles []TitleNode `json:"titles"`
|
||||
}
|
||||
|
||||
// Title 节点:Title-1、Title-2...
|
||||
type TitleNode struct {
|
||||
Title string `json:"title"` // 标题+编号:如 通勤-1
|
||||
HtmlFileUrl string `json:"htmlFileUrl"` // html地址
|
||||
ImageUrls []ImgNode `json:"imageUrls"` // 图片列表
|
||||
}
|
||||
|
||||
// 图片
|
||||
type ImgNode struct {
|
||||
Name string `json:"name"` // img+1
|
||||
Url string `json:"url"`
|
||||
}
|
||||
31
workflow/model/entity/creation_info.go
Normal file
31
workflow/model/entity/creation_info.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package entity
|
||||
|
||||
import "gitea.com/red-future/common/beans"
|
||||
|
||||
type CreationInfo struct {
|
||||
beans.SQLBaseDO `orm:",inherit"` // 嵌入基础字段:Id, TenantId, Creator, CreatedAt, Updater, UpdatedAt, DeletedAt
|
||||
// 业务字段
|
||||
HtmlFileUrl string `orm:"html_file_url" json:"htmlFileUrl"`
|
||||
ImageUrls []string `orm:"image_urls" json:"imageUrls"`
|
||||
ContentType string `orm:"content_type" json:"contentType"`
|
||||
Theme string `orm:"theme" json:"theme"`
|
||||
Title string `orm:"title" json:"title"`
|
||||
}
|
||||
|
||||
type creationInfoCol struct {
|
||||
beans.SQLBaseCol
|
||||
HtmlFileUrl string
|
||||
ImageUrls string
|
||||
ContentType string
|
||||
Theme string
|
||||
Title string
|
||||
}
|
||||
|
||||
var CreationInfoCol = creationInfoCol{
|
||||
SQLBaseCol: beans.DefSQLBaseCol,
|
||||
HtmlFileUrl: "html_file_url",
|
||||
ImageUrls: "image_urls",
|
||||
ContentType: "content_type",
|
||||
Theme: "theme",
|
||||
Title: "title",
|
||||
}
|
||||
191
workflow/service/creation_info_service.go
Normal file
191
workflow/service/creation_info_service.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/dao"
|
||||
"ai-agent/workflow/model/dto"
|
||||
"ai-agent/workflow/model/entity"
|
||||
"ai-agent/workflow/service/einograph"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var CreationInfoService = new(creationInfoService)
|
||||
|
||||
type creationInfoService struct{}
|
||||
|
||||
func (s *creationInfoService) Creation(ctx context.Context, req *dto.CreationInput) (err error) {
|
||||
run, err := einograph.BuildCreationGraph(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建失败: %w", err)
|
||||
}
|
||||
|
||||
output, err := run.Invoke(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("执行失败: %w", err)
|
||||
}
|
||||
// ========== 核心修复:先转 map,再取 output ==========
|
||||
var resultMap map[string]any
|
||||
if err := gconv.Scan(output, &resultMap); err != nil {
|
||||
return fmt.Errorf("解析结果map失败: %w", err)
|
||||
}
|
||||
|
||||
// 取出内层 output
|
||||
realOutput := resultMap["output"]
|
||||
|
||||
// 解析到你的结构体
|
||||
var creationOutput dto.CreationOutput
|
||||
if err := gconv.Scan(realOutput, &creationOutput); err != nil {
|
||||
return fmt.Errorf("解析CreationOutput失败: %w", err)
|
||||
}
|
||||
for _, item := range creationOutput.Items {
|
||||
dao.CreationInfoDao.Insert(ctx, &dto.Create{
|
||||
ImageUrls: item.ImageUrls,
|
||||
HtmlFileUrl: item.HtmlFileUrl,
|
||||
Title: item.Title,
|
||||
Theme: item.Theme,
|
||||
ContentType: item.ContentType,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *creationInfoService) List(ctx context.Context, req *dto.ListCreationInfoReq) (res *dto.ListCreationInfoRes, err error) {
|
||||
user, err := utils.GetUserInfo(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Creator = user.UserName
|
||||
list, total, err := dao.CreationInfoDao.List(ctx, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
res = &dto.ListCreationInfoRes{
|
||||
Total: total,
|
||||
}
|
||||
res.ImgAddressPrefix, err = utils.GetFileAddressPrefix(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tree := s.ConvertToTree(list)
|
||||
res.Tree = tree
|
||||
return
|
||||
}
|
||||
|
||||
// ConvertToTree
|
||||
// 第一层:日期 倒序
|
||||
// 第二层:ContentType 倒序
|
||||
// 第三层:Theme 倒序
|
||||
// Theme 下的 Title 按时间倒序编号 Title-1、Title-2…
|
||||
func (s *creationInfoService) ConvertToTree(list []*entity.CreationInfo) []dto.TimeNode {
|
||||
// ========== 1. 按日期分组 ==========
|
||||
timeMap := make(map[string][]*entity.CreationInfo)
|
||||
for _, item := range list {
|
||||
dateStr := item.CreatedAt.Format("Y-m-d")
|
||||
timeMap[dateStr] = append(timeMap[dateStr], item)
|
||||
}
|
||||
|
||||
// 日期倒序
|
||||
var dateList []string
|
||||
for dateStr := range timeMap {
|
||||
dateList = append(dateList, dateStr)
|
||||
}
|
||||
sort.Slice(dateList, func(i, j int) bool {
|
||||
return dateList[i] > dateList[j]
|
||||
})
|
||||
|
||||
var tree []dto.TimeNode
|
||||
|
||||
// ========== 2. 遍历日期 ==========
|
||||
for _, dateStr := range dateList {
|
||||
items := timeMap[dateStr]
|
||||
|
||||
// ========== ContentType 分组 + 【时间倒序】 ==========
|
||||
ctMap := make(map[string][]*entity.CreationInfo)
|
||||
for _, item := range items {
|
||||
ctMap[item.ContentType] = append(ctMap[item.ContentType], item)
|
||||
}
|
||||
|
||||
// 把 ContentType 按【最新时间倒序】排序
|
||||
var ctList []string
|
||||
for ct := range ctMap {
|
||||
ctList = append(ctList, ct)
|
||||
}
|
||||
sort.Slice(ctList, func(i, j int) bool {
|
||||
ctiItems := ctMap[ctList[i]]
|
||||
ctjItems := ctMap[ctList[j]]
|
||||
return ctiItems[0].CreatedAt.After(ctjItems[0].CreatedAt)
|
||||
})
|
||||
|
||||
var ctNodes []dto.ContentTypeNode
|
||||
for _, ct := range ctList {
|
||||
ctItems := ctMap[ct]
|
||||
|
||||
// ========== Theme 分组 + 【时间倒序】 ==========
|
||||
themeMap := make(map[string][]*entity.CreationInfo)
|
||||
for _, item := range ctItems {
|
||||
themeMap[item.Theme] = append(themeMap[item.Theme], item)
|
||||
}
|
||||
|
||||
// Theme 按【最新时间倒序】排序
|
||||
var themeList []string
|
||||
for theme := range themeMap {
|
||||
themeList = append(themeList, theme)
|
||||
}
|
||||
sort.Slice(themeList, func(i, j int) bool {
|
||||
tiItems := themeMap[themeList[i]]
|
||||
tjItems := themeMap[themeList[j]]
|
||||
return tiItems[0].CreatedAt.After(tjItems[0].CreatedAt)
|
||||
})
|
||||
|
||||
var themeNodes []dto.ThemeNode
|
||||
for _, theme := range themeList {
|
||||
themeItems := themeMap[theme]
|
||||
|
||||
// ========== Theme 下的 Title 按【时间倒序】排列 + 编号 ==========
|
||||
sort.Slice(themeItems, func(a, b int) bool {
|
||||
return themeItems[a].CreatedAt.After(themeItems[b].CreatedAt)
|
||||
})
|
||||
|
||||
var titleNodes []dto.TitleNode
|
||||
for idx, item := range themeItems {
|
||||
titleName := fmt.Sprintf("%s-%d", item.Title, idx+1)
|
||||
|
||||
var imgList []dto.ImgNode
|
||||
for imgIdx, url := range item.ImageUrls {
|
||||
imgList = append(imgList, dto.ImgNode{
|
||||
Name: fmt.Sprintf("img-%d", imgIdx+1),
|
||||
Url: url,
|
||||
})
|
||||
}
|
||||
|
||||
titleNodes = append(titleNodes, dto.TitleNode{
|
||||
Title: titleName,
|
||||
HtmlFileUrl: item.HtmlFileUrl,
|
||||
ImageUrls: imgList,
|
||||
})
|
||||
}
|
||||
|
||||
themeNodes = append(themeNodes, dto.ThemeNode{
|
||||
Theme: theme,
|
||||
Titles: titleNodes,
|
||||
})
|
||||
}
|
||||
|
||||
ctNodes = append(ctNodes, dto.ContentTypeNode{
|
||||
ContentType: ct,
|
||||
Themes: themeNodes,
|
||||
})
|
||||
}
|
||||
|
||||
tree = append(tree, dto.TimeNode{
|
||||
CreatedDate: dateStr,
|
||||
ContentTypes: ctNodes,
|
||||
})
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
409
workflow/service/einograph/lambda_func.go
Normal file
409
workflow/service/einograph/lambda_func.go
Normal file
@@ -0,0 +1,409 @@
|
||||
package einograph
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/model/dto"
|
||||
"ai-agent/workflow/skill"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
commonHttp "gitea.com/red-future/common/http"
|
||||
"gitea.com/red-future/common/utils"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// 全局保存请求(最简单、最稳定、Eino 必不报错)
|
||||
var currentReq *dto.CreationInput
|
||||
|
||||
// ==================== 1. 输入适配 ====================
|
||||
func InputAdaptLambda(ctx context.Context, input any) (any, error) {
|
||||
// 直接保存到全局
|
||||
req, ok := input.(*dto.CreationInput)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("input must be CreationInput")
|
||||
}
|
||||
currentReq = req
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ==================== 2. 构建Prompt ====================
|
||||
func BuildPromptLambda(ctx context.Context, input any) (any, error) {
|
||||
req, ok := input.(*dto.CreationInput)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("input must be CreationInput")
|
||||
}
|
||||
count := req.Count
|
||||
if req.Count > 3 {
|
||||
req.Count = 3
|
||||
}
|
||||
|
||||
imagePerPost := req.ImagePerPost
|
||||
if req.ImagePerPost > 3 {
|
||||
imagePerPost = 3
|
||||
}
|
||||
|
||||
var imgPlaceholder strings.Builder
|
||||
for i := 1; i <= imagePerPost; i++ {
|
||||
imgPlaceholder.WriteString(fmt.Sprintf(`<div class="img-box"><img src="{{IMG_URL_%d}}"></div>`, i))
|
||||
}
|
||||
mode := req.Mode
|
||||
|
||||
// 核心:先用 embed 读取技能文案(完全不依赖运行目录)
|
||||
skillContent, err := skill.ReadSkillMD()
|
||||
prompt := ""
|
||||
if len(skillContent) != 0 && err == nil {
|
||||
prompt = skillContent
|
||||
}
|
||||
|
||||
// 根据模式拼接(动态图片数量 + 动态比例 + 严格数量控制)
|
||||
switch mode {
|
||||
case "混合模式(文案 + 图片)":
|
||||
s1 := `1. 整个输出只能有 %d 个 <html> 标签和 %d 个</html> 标签`
|
||||
var a = fmt.Sprintf(`%s%d%d`, s1, count, count)
|
||||
s2 := `2. 生成完成后立即停止,不续写、不扩展、不重复
|
||||
3. 不要输出任何解释、前言、后语
|
||||
4. 只输出纯HTML代码
|
||||
5. 内容绝对不能重复!`
|
||||
var b = fmt.Sprintf(`%s%s`, a, s2)
|
||||
s3 := `6. 多个HTML文件之间必须用 --- 分隔,分隔符前后不能有任何多余文字!`
|
||||
if count > 1 {
|
||||
b = fmt.Sprintf(`%s%s`, b, s3)
|
||||
}
|
||||
prompt = fmt.Sprintf(`%s
|
||||
|
||||
【🔴 最高优先级强制规则】
|
||||
%s
|
||||
|
||||
【📄 HTML结构强制模板】
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; font-family:system-ui, -apple-system, sans-serif; }
|
||||
body { background:#F0F2F5; padding:16px; }
|
||||
.card { background:#fff; border-radius:16px; padding:18px; max-width:500px; margin:0 auto; }
|
||||
.title { font-size:22px; font-weight:700; margin-bottom:14px; line-height:1.4; }
|
||||
|
||||
/* 动态图片比例:由参数决定 */
|
||||
.img-box {
|
||||
width:100%%;
|
||||
aspect-ratio:%s;
|
||||
border-radius:12px;
|
||||
overflow:hidden;
|
||||
margin-bottom:14px;
|
||||
}
|
||||
.img-box img { width:100%%; height:100%%; object-fit:cover; display:block; }
|
||||
|
||||
.content { font-size:16px; line-height:1.7; color:#333; margin-bottom:20px; }
|
||||
.content p { margin:8px 0; }
|
||||
.tags { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.tag { background:#f5f5f5; padding:6px 12px; border-radius:20px; font-size:14px; color:#666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2 class="title">%s</h2>
|
||||
|
||||
<!-- 图片数量由前端参数决定,直接使用传入的图片,不准修改 -->
|
||||
%s
|
||||
|
||||
<div class="content">
|
||||
请按照小红书风格创作文案:分段清晰、使用emoji、重点内容加粗、排版美观
|
||||
</div>
|
||||
|
||||
<div class="tags">
|
||||
<span class="tag">#干货分享</span>
|
||||
<span class="tag">#实用技巧</span>
|
||||
<span class="tag">#知识科普</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
【📌 必须遵守的细节规则】
|
||||
1. 所有图片必须使用 .img-box 容器,不变形、不拉伸。
|
||||
2. 文案必须分段清晰、美观、小红书风格、使用emoji、重点内容加粗,排版符合小红书风格。
|
||||
3. 图片占位符里有几张图,就生成几张,不要自己额外添加或删除。
|
||||
4. 标签自动根据主题生成,无需外部参数。
|
||||
|
||||
内容信息:
|
||||
主题:%s
|
||||
标题:%s
|
||||
风格:%s
|
||||
`,
|
||||
prompt,
|
||||
b,
|
||||
req.ImageRatio,
|
||||
req.Title,
|
||||
imgPlaceholder.String(), // 你传入的图片(多张自动适配)
|
||||
req.Theme,
|
||||
req.Title,
|
||||
req.Style,
|
||||
)
|
||||
|
||||
case "纯文案模式":
|
||||
s1 := `1. 整个输出只能有 %d 个 <html> 标签和 %d 个</html> 标签`
|
||||
var a = fmt.Sprintf(`%s%d%d`, s1, count, count)
|
||||
s2 := `2. 生成完成后立即停止,不续写、不扩展、不重复
|
||||
3. 不要输出任何解释、前言、后语
|
||||
4. 只输出纯HTML代码
|
||||
5. 内容绝对不能重复!`
|
||||
var b = fmt.Sprintf(`%s%s`, a, s2)
|
||||
s3 := `6. 多个HTML文件之间必须用 --- 分隔,分隔符前后不能有任何多余文字!`
|
||||
if count > 1 {
|
||||
b = fmt.Sprintf(`%s%s`, b, s3)
|
||||
}
|
||||
prompt = fmt.Sprintf(`%s
|
||||
|
||||
【🔴 最高优先级强制规则】
|
||||
%s
|
||||
|
||||
【📄 HTML结构强制模板】
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; font-family:system-ui, -apple-system, sans-serif; }
|
||||
body { background:#F0F2F5; padding:16px; }
|
||||
.card { background:#fff; border-radius:16px; padding:18px; max-width:500px; margin:0 auto; }
|
||||
.title { font-size:22px; font-weight:700; margin-bottom:14px; }
|
||||
.content { font-size:16px; line-height:1.7; color:#333; margin-bottom:20px; }
|
||||
.tags { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.tag { background:#f5f5f5; padding:6px 12px; border-radius:20px; font-size:14px; color:#666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2 class="title">%s</h2>
|
||||
<div class="content">按照小红书风格创作文案:分段+emoji+重点加粗,排版美观</div>
|
||||
<div class="tags">
|
||||
<span class="tag">#干货分享</span>
|
||||
<span class="tag">#实用技巧</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
【📌 必须遵守的细节规则】
|
||||
2. 必须分段清晰、美观、小红书风格、使用emoji、重点内容加粗,排版符合小红书风格。
|
||||
3. 标签自动根据主题生成,无需外部参数。
|
||||
|
||||
内容:主题=%s,标题=%s,风格=%s
|
||||
`,
|
||||
prompt,
|
||||
b,
|
||||
req.Title,
|
||||
req.Theme,
|
||||
req.Title,
|
||||
req.Style,
|
||||
)
|
||||
|
||||
case "纯图片模式":
|
||||
s1 := `1. 必须**严格且只能生成 %d 组绘图关键词**,绝对不能多生成一组,也不能少生成一组!`
|
||||
var a = fmt.Sprintf(`%s%d`, s1, count)
|
||||
s2 := `2. 生成完成后立即停止,不续写、不扩展、不重复
|
||||
3. 所有输出内容只能是关键词本身,**绝对不能出现任何解释、说明、额外文字**!
|
||||
4. 内容绝对不能重复!`
|
||||
var b = fmt.Sprintf(`%s%s`, a, s2)
|
||||
s3 := `5. 多组关键词之间必须用 --- 分隔,分隔符前后不能有任何多余文字!`
|
||||
if count > 1 {
|
||||
b = fmt.Sprintf(`%s%s`, b, s3)
|
||||
}
|
||||
|
||||
prompt = fmt.Sprintf(`%s
|
||||
|
||||
【🔴 最高优先级强制规则】
|
||||
%s
|
||||
|
||||
内容:主题:%s,比例:%s,风格:%s
|
||||
`,
|
||||
prompt,
|
||||
b,
|
||||
req.Theme,
|
||||
req.ImageRatio,
|
||||
req.Style,
|
||||
)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持模式")
|
||||
}
|
||||
|
||||
if !g.IsEmpty(req.Desc) {
|
||||
prompt = fmt.Sprintf(`%s,要求:%s`, prompt, req.Desc)
|
||||
}
|
||||
|
||||
return []*schema.Message{schema.UserMessage(prompt)}, nil
|
||||
}
|
||||
|
||||
// ==================== 4. 生成+上传(完全不解析input!) ====================
|
||||
func GenerateImageLambda(ctx context.Context, input any) (any, error) {
|
||||
|
||||
msg, ok := input.(*schema.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("input must be *schema.Message")
|
||||
}
|
||||
optimizedText := strings.TrimSpace(msg.Content)
|
||||
|
||||
list := strings.Split(optimizedText, "---")
|
||||
var items []string
|
||||
for _, s := range list {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
items = append(items, s)
|
||||
}
|
||||
}
|
||||
imgAddressPrefix, err := utils.GetFileAddressPrefix(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
}
|
||||
mode := currentReq.Mode
|
||||
|
||||
imagePerPost := currentReq.ImagePerPost
|
||||
if currentReq.ImagePerPost > 3 {
|
||||
imagePerPost = 3
|
||||
}
|
||||
// ==============================================
|
||||
// ✅ 核心修复:构建正确的绘图关键词(主题+标题+风格+比例)
|
||||
// ==============================================
|
||||
prompt := fmt.Sprintf(
|
||||
"%s,%s,%s,风格:%s,比例:%s",
|
||||
currentReq.ContentType,
|
||||
currentReq.Theme,
|
||||
currentReq.Title,
|
||||
currentReq.Style,
|
||||
currentReq.ImageRatio,
|
||||
)
|
||||
if !g.IsEmpty(currentReq.Desc) {
|
||||
prompt = fmt.Sprintf(`%s,要求:%s`, prompt, currentReq.Desc)
|
||||
}
|
||||
// 初始化返回结果
|
||||
var output dto.CreationOutput
|
||||
|
||||
for idx, item := range items {
|
||||
base := fmt.Sprintf("%s_%d", currentReq.Title, idx+1)
|
||||
dir := currentReq.Theme
|
||||
num := imagePerPost
|
||||
var uploadItem dto.ImageUploadItem
|
||||
uploadItem.Title = currentReq.Title
|
||||
uploadItem.Index = idx + 1
|
||||
uploadItem.Theme = currentReq.Theme
|
||||
uploadItem.ContentType = currentReq.ContentType
|
||||
|
||||
switch mode {
|
||||
case "混合模式(文案 + 图片)":
|
||||
// 生成并上传多张图片
|
||||
var imageUrls []string
|
||||
imgMap := make(map[int]string)
|
||||
for i := 1; i <= num; i++ {
|
||||
url, _ := GenerateRealImage(prompt)
|
||||
imageUrls = append(imageUrls, url) // 收集图片URL
|
||||
bs, _ := getImageBytesFromURL(url)
|
||||
uploadResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileName: fmt.Sprintf("%s_%d.png", base, i),
|
||||
FileBytes: bs,
|
||||
FileStoreURL: dir,
|
||||
})
|
||||
// 如果Upload返回了最终访问URL,替换成真实返回值
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imageUrls[i-1] = uploadResp.FileURL
|
||||
imgMap[i] = uploadResp.FileURL
|
||||
}
|
||||
uploadItem.ImageUrls = imageUrls // 存入结构体
|
||||
|
||||
// 替换HTML
|
||||
final := item
|
||||
if imagePerPost > 1 {
|
||||
for i := 1; i <= num; i++ {
|
||||
final = strings.ReplaceAll(final, fmt.Sprintf("{{IMG_URL_%d}}", i), imgAddressPrefix+imgMap[i])
|
||||
}
|
||||
} else {
|
||||
for i := 1; i <= num; i++ {
|
||||
final = strings.ReplaceAll(final, fmt.Sprintf("{{IMG_URL_%d}}", idx+1), imgAddressPrefix+imgMap[i])
|
||||
}
|
||||
}
|
||||
htmlUploadResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileName: base + ".html",
|
||||
FileBytes: []byte(final),
|
||||
FileStoreURL: dir,
|
||||
})
|
||||
// 保存HTML上传地址
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadItem.HtmlFileUrl = htmlUploadResp.FileURL
|
||||
|
||||
case "纯图片模式":
|
||||
var realImageUrls []string
|
||||
for i := 1; i <= num; i++ {
|
||||
tempUrl, _ := GenerateRealImage(prompt)
|
||||
bs, _ := getImageBytesFromURL(tempUrl)
|
||||
uploadResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileName: fmt.Sprintf("%s_%d.png", base, i),
|
||||
FileBytes: bs,
|
||||
FileStoreURL: dir,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
realImageUrls = append(realImageUrls, uploadResp.FileURL)
|
||||
}
|
||||
uploadItem.ImageUrls = realImageUrls
|
||||
|
||||
case "纯文案模式":
|
||||
htmlResp, err := Upload(ctx, &dto.UploadFileBytesReq{
|
||||
FileName: base + ".html",
|
||||
FileBytes: []byte(item),
|
||||
FileStoreURL: dir,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadItem.HtmlFileUrl = htmlResp.FileURL
|
||||
}
|
||||
|
||||
output.Items = append(output.Items, uploadItem)
|
||||
fmt.Printf("✅ 第%d条上传成功\n,html:%s\n图片:%s\n", idx+1, gconv.String(uploadItem.HtmlFileUrl), gconv.String(uploadItem.ImageUrls))
|
||||
}
|
||||
|
||||
// 统计成功数量
|
||||
output.SuccessCount = len(output.Items)
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
func getImageBytesFromURL(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) {
|
||||
headers := make(map[string]string)
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
for k, v := range r.Header {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
res := &dto.UploadFileBytesRes{}
|
||||
err := commonHttp.Post(ctx, "oss/file/uploadFileBytes", headers, res, req)
|
||||
if err != nil {
|
||||
glog.Error(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
98
workflow/service/einograph/model.go
Normal file
98
workflow/service/einograph/model.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package einograph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/qwen"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// NewChatModel component initialization function of node 'ChatModel1' in graph 'test'
|
||||
func NewChatModel(ctx context.Context) (cm model.ChatModel, err error) {
|
||||
// TODO Modify component configuration here.
|
||||
config := &qwen.ChatModelConfig{
|
||||
APIKey: "sk-4a8b82770bf74bc490eb3e4c5a8e2be9",
|
||||
Model: "qwen-turbo",
|
||||
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", // 必须加!
|
||||
MaxTokens: gconv.PtrInt(2000),
|
||||
Temperature: gconv.PtrFloat32(float32(0.7))}
|
||||
cm, err = qwen.NewChatModel(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
func GenerateRealImage(prompt string) (string, error) {
|
||||
apiKey := "sk-4a8b82770bf74bc490eb3e4c5a8e2be9"
|
||||
url := "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
|
||||
body := map[string]any{
|
||||
"model": "qwen-image",
|
||||
"input": map[string]any{
|
||||
"messages": []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]string{
|
||||
{"type": "text", "text": prompt},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"parameters": map[string]any{
|
||||
"size": "1024*1364",
|
||||
"n": 1,
|
||||
"watermark": false,
|
||||
},
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
|
||||
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()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// ✅ 修复:plus 模型返回的字段是 image 不是 image_url
|
||||
var result struct {
|
||||
Output struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content []struct {
|
||||
Image string `json:"image"`
|
||||
} `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
} `json:"output"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
err = gconv.Struct(data, &result)
|
||||
|
||||
if len(result.Output.Choices) == 0 || result.Code != "" {
|
||||
return "", fmt.Errorf("生成失败: %s", string(data))
|
||||
}
|
||||
|
||||
// ✅ 修复:直接取 base64,不再下载
|
||||
imgBase64 := result.Output.Choices[0].Message.Content[0].Image
|
||||
if imgBase64 == "" {
|
||||
return "", fmt.Errorf("图片为空")
|
||||
}
|
||||
|
||||
// 解码 base64
|
||||
return imgBase64, nil
|
||||
}
|
||||
80
workflow/service/einograph/orchestration.go
Normal file
80
workflow/service/einograph/orchestration.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package einograph
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// 节点名称常量
|
||||
const (
|
||||
NodeInputAdapt = "input_adapt" // 输入适配
|
||||
NodeBuildPrompt = "build_prompt" // 构建提示词
|
||||
NodeGenText = "gen_text" // 大模型生成原始文案
|
||||
NodeGenImage = "gen_image" // 生成图片 + HTML
|
||||
)
|
||||
|
||||
// ==================== 构建流式图(已加入优化节点) ====================
|
||||
func BuildCreationGraph(ctx context.Context) (compose.Runnable[any, any], error) {
|
||||
g := compose.NewGraph[any, any]()
|
||||
|
||||
// 1. 输入适配
|
||||
err := g.AddLambdaNode(
|
||||
NodeInputAdapt,
|
||||
compose.InvokableLambda(InputAdaptLambda),
|
||||
compose.WithOutputKey("input"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 构建提示词
|
||||
err = g.AddLambdaNode(
|
||||
NodeBuildPrompt,
|
||||
compose.InvokableLambda(BuildPromptLambda),
|
||||
compose.WithInputKey("input"),
|
||||
compose.WithOutputKey("messages"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. LLM 生成原始文案
|
||||
chatModel, err := NewChatModel(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = g.AddChatModelNode(
|
||||
NodeGenText,
|
||||
chatModel,
|
||||
compose.WithInputKey("messages"),
|
||||
compose.WithOutputKey("text_result"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. 生成图片 + HTML(使用优化后的文案)
|
||||
err = g.AddLambdaNode(
|
||||
NodeGenImage,
|
||||
compose.InvokableLambda(GenerateImageLambda),
|
||||
compose.WithInputKey("text_result"),
|
||||
compose.WithOutputKey("output"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ✅ 正确连线:优化节点必须放在生成文案之后、生成图片之前
|
||||
_ = g.AddEdge(compose.START, NodeInputAdapt)
|
||||
_ = g.AddEdge(NodeInputAdapt, NodeBuildPrompt)
|
||||
_ = g.AddEdge(NodeBuildPrompt, NodeGenText)
|
||||
_ = g.AddEdge(NodeGenText, NodeGenImage)
|
||||
_ = g.AddEdge(NodeGenImage, compose.END)
|
||||
|
||||
// 编译
|
||||
return g.Compile(ctx,
|
||||
compose.WithGraphName("xiaohongshu_content_creation"),
|
||||
compose.WithNodeTriggerMode(compose.AnyPredecessor),
|
||||
)
|
||||
}
|
||||
71
workflow/skill/SKILL.md
Normal file
71
workflow/skill/SKILL.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: xiaohongshu-writer-expert
|
||||
description: 根据任意主题生成小红书爆款文案,自动匹配8种风格模板,输出包含正文、配图描述、封面文案和搜索关键词的完整图文方案
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# 小红书爆款图文写作专家
|
||||
|
||||
你是一位资深小红书内容创作专家,擅长将任意主题转化为吸引眼球、高互动率的爆款文案。
|
||||
|
||||
## 核心能力
|
||||
- 自动判断主题类型,匹配最合适的风格模板
|
||||
- 生成200-300字年轻化、口语化文案
|
||||
- 提供完整的图文配套方案(配图描述、封面文案、搜索关键词)
|
||||
|
||||
## 可用风格模板
|
||||
1. **好物推荐型**:产品推荐、购物分享、开箱测评
|
||||
2. **干货教程型**:技能教学、知识分享、避坑指南
|
||||
3. **情感成长型**:个人经历、成长感悟、励志分享
|
||||
4. **探店打卡型**:美食探店、旅行打卡、咖啡店分享
|
||||
5. **日常vlog型**:生活方式、日常记录、好习惯养成
|
||||
6. **母婴亲子型**:育儿经验、宝宝好物、亲子互动
|
||||
7. **穿搭美妆型**:妆容教程、穿搭分享、变美技巧
|
||||
8. **创业副业型**:副业分享、搞钱经验、成长干货
|
||||
|
||||
## 工作流程
|
||||
1. **主题分析**:判断内容属于哪个类型,选择最匹配的模板
|
||||
2. **文案创作**:
|
||||
- 开头:2-3个emoji + 10字以内爆款标题 + 一句话引入
|
||||
- 正文:3-5个要点,每个带emoji,语言口语化,适当加空行
|
||||
- 总结:金句/核心观点 + 行动呼吁
|
||||
- 互动:自然引导点赞、收藏、评论
|
||||
- 标签:5-8个相关话题
|
||||
3. **图文配套**:
|
||||
- 配图描述:画面风格、主体、色调、氛围
|
||||
- 封面文案:1-2句,分行排版,每行不超过5字
|
||||
- 英文关键词:3-5个用于图库搜索
|
||||
|
||||
## 文案风格要点
|
||||
- 语言年轻化、口语化,善用"咱就是说"、"真的绝了"、"谁懂啊"等网络用语
|
||||
- 适当使用括号补充细节,增加真实感
|
||||
- 每段文字不宜过长,手机阅读舒适为主
|
||||
- emoji与文字比例适中,不喧宾夺主
|
||||
- 互动语要具体,不要只说"欢迎评论"
|
||||
- 标题要有信息增量,让人有点击欲望
|
||||
|
||||
## 输出格式
|
||||
直接输出以下结构,无需额外说明:
|
||||
|
||||
## 📝 小红书图文
|
||||
|
||||
[emoji] [标题]
|
||||
[空行]
|
||||
[正文内容,适当加空行分隔]
|
||||
[空行]
|
||||
[互动引导语]
|
||||
[空行]
|
||||
[标签列表]
|
||||
|
||||
---
|
||||
|
||||
## 🎨 配图方案
|
||||
|
||||
**配图描述**:[描述图片风格、画面内容、色调、构图]
|
||||
|
||||
**封面文案**:
|
||||
[文案第1行]
|
||||
[文案第2行]
|
||||
[文案第3行]
|
||||
|
||||
**搜索关键词**:[keyword1, keyword2, keyword3, keyword4, keyword5]
|
||||
15
workflow/skill/embed.go
Normal file
15
workflow/skill/embed.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package skill
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.md
|
||||
var SkillFS embed.FS
|
||||
|
||||
// ReadSkillMD 读取 SKILL.md 内容
|
||||
func ReadSkillMD() (string, error) {
|
||||
data, err := SkillFS.ReadFile("SKILL.md")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
Reference in New Issue
Block a user