feat: 新增创作作品管理模块及相关配置

This commit is contained in:
2026-05-08 11:35:15 +08:00
parent ed333dd15c
commit 74ede5bc0f
16 changed files with 1373 additions and 4 deletions

View 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
}

View 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条上传成功\nhtml%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
}

View 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
}

View 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),
)
}