公众号助手新增图片

This commit is contained in:
Jackzhou
2026-08-26 17:34:21 +08:00
parent b00e55f0e5
commit f864cc2c87
11 changed files with 1348 additions and 30 deletions
@@ -17,6 +17,7 @@ import (
const (
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
)
@@ -25,6 +26,7 @@ const (
var CapabilityCredits = map[string]int{
CapabilityAIChat: 1,
CapabilityTextGen: 1,
CapabilityImageGen: 1,
CapabilityEmbed: 0,
CapabilityEssayGrade: 0,
}
@@ -73,6 +73,28 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
workflow.Shared.SelectedTitle = article.SelectedTitle
workflow.Shared.Outline = article.Outline
workflow.Shared.Content = article.Content
workflow.Shared.ContentWithPrompts = article.ContentWithPrompts
if strings.TrimSpace(article.ImagePromptsJSON) != "" {
var items []officialAccountImagePrompt
if json.Unmarshal([]byte(article.ImagePromptsJSON), &items) == nil {
workflow.Shared.ImagePrompts = items
}
}
if strings.TrimSpace(article.ImageRefsJSON) != "" {
var items []officialAccountImageRef
if json.Unmarshal([]byte(article.ImageRefsJSON), &items) == nil {
workflow.Shared.ImageRefs = items
}
}
workflow.Shared.PreviewMarkdown = article.PreviewMarkdown
workflow.Shared.PreviewHTML = article.PreviewHTML
workflow.Shared.PageMarkdown = article.PageMarkdown
if strings.TrimSpace(article.PagesJSON) != "" {
var items []officialAccountPageChunk
if json.Unmarshal([]byte(article.PagesJSON), &items) == nil {
workflow.Shared.Pages = items
}
}
}
func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticle, workflow officialAccountWorkflowState) {
@@ -92,14 +114,35 @@ func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticl
article.SelectedTitle = workflow.Shared.SelectedTitle
article.Outline = workflow.Shared.Outline
article.Content = workflow.Shared.Content
article.ContentWithPrompts = workflow.Shared.ContentWithPrompts
if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil {
article.TopicCandidatesJSON = string(data)
}
if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil {
article.TitleCandidatesJSON = string(data)
}
if workflow.Shared.Content != "" {
article.Status = "content_ready"
if data, err := json.Marshal(workflow.Shared.ImagePrompts); err == nil {
article.ImagePromptsJSON = string(data)
}
if data, err := json.Marshal(workflow.Shared.ImageRefs); err == nil {
article.ImageRefsJSON = string(data)
}
article.PreviewMarkdown = workflow.Shared.PreviewMarkdown
article.PreviewHTML = workflow.Shared.PreviewHTML
article.PageMarkdown = workflow.Shared.PageMarkdown
if data, err := json.Marshal(workflow.Shared.Pages); err == nil {
article.PagesJSON = string(data)
}
if workflow.Shared.PageMarkdown != "" {
article.Status = "page_md_ready"
} else if workflow.Shared.PreviewHTML != "" {
article.Status = "preview_ready"
} else if len(workflow.Shared.ImageRefs) > 0 {
article.Status = "images_ready"
} else if len(workflow.Shared.ImagePrompts) > 0 || workflow.Shared.ContentWithPrompts != "" {
article.Status = "image_prompts_ready"
} else if workflow.Shared.Content != "" {
article.Status = "content_ready"
} else if workflow.Shared.Outline != "" {
article.Status = "outline_ready"
} else if workflow.Shared.SelectedTitle != "" {
@@ -132,6 +175,13 @@ func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req offi
article.Outline = ""
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
article.Content = ""
article.ContentWithPrompts = ""
article.ImagePromptsJSON = "[]"
article.ImageRefsJSON = "[]"
article.PreviewMarkdown = ""
article.PreviewHTML = ""
article.PageMarkdown = ""
article.PagesJSON = "[]"
article.Status = "draft"
}
@@ -0,0 +1,593 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/web"
)
type officialAccountSection struct {
Title string
Content []string
}
func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
prompts := buildOfficialAccountImagePrompts(workflow)
contentWithPrompts := buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts)
workflow.Shared.ContentWithPrompts = contentWithPrompts
workflow.Shared.ImagePrompts = prompts
output := map[string]any{
"content_with_prompts": contentWithPrompts,
"image_prompts": prompts,
"count": len(prompts),
}
return officialAccountStepExecution{
Summary: fmt.Sprintf("已生成 %d 条配图提示词。", len(prompts)),
TaskStatus: workerTaskStatusDraft,
ArtifactTitle: "公众号配图提示词",
ArtifactType: "image_prompts",
ArtifactStatus: workerArtifactStatusDraft,
ArtifactContent: contentWithPrompts,
Output: output,
Logs: []string{
fmt.Sprintf("%s 已基于正文提炼配图提示词。", now.Format("15:04:05")),
fmt.Sprintf("%s 已写回带提示词标记的正文稿。", now.Format("15:04:05")),
},
}
}
func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) {
prompts := workflow.Shared.ImagePrompts
if len(prompts) == 0 {
prompts = buildOfficialAccountImagePrompts(workflow)
workflow.Shared.ImagePrompts = prompts
workflow.Shared.ContentWithPrompts = buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts)
}
route, refs, appErr := generateOfficialAccountImages(article.TaskID, workflow, prompts, user)
if appErr != nil {
return officialAccountStepExecution{}, appErr
}
workflow.Shared.ImageRefs = refs
output := map[string]any{
"image_refs": refs,
"count": len(refs),
"route_id": route.RouteID,
"route_model": route.Model,
"route_provider": route.Provider,
}
return officialAccountStepExecution{
Summary: fmt.Sprintf("已生成 %d 张图片结果。", len(refs)),
TaskStatus: workerTaskStatusDraft,
ArtifactTitle: "公众号配图结果",
ArtifactType: "images",
ArtifactStatus: workerArtifactStatusDraft,
ArtifactContent: buildOfficialAccountImageArtifact(refs),
Output: output,
Logs: []string{
fmt.Sprintf("%s 已通过 %s 生成图片结果。", now.Format("15:04:05"), route.Model),
fmt.Sprintf("%s 图片已可直接用于预览与导出。", now.Format("15:04:05")),
},
}, nil
}
func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
previewMarkdown := buildOfficialAccountPreviewMarkdown(workflow)
previewHTML := renderOfficialAccountHTML(previewMarkdown)
workflow.Shared.PreviewMarkdown = previewMarkdown
workflow.Shared.PreviewHTML = previewHTML
output := map[string]any{
"preview_markdown": previewMarkdown,
"preview_html": previewHTML,
"image_count": len(workflow.Shared.ImageRefs),
}
return officialAccountStepExecution{
Summary: "预览稿已生成,可直接检查排版并导出。",
TaskStatus: workerTaskStatusDraft,
ArtifactTitle: "公众号预览稿",
ArtifactType: "preview",
ArtifactStatus: workerArtifactStatusDraft,
ArtifactContent: previewMarkdown,
Output: output,
Logs: []string{
fmt.Sprintf("%s 已整合正文与配图生成预览稿。", now.Format("15:04:05")),
fmt.Sprintf("%s 已同步产出预览 HTML。", now.Format("15:04:05")),
},
}
}
func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
pages := buildOfficialAccountPages(workflow)
pageMarkdown := buildOfficialAccountPageMarkdown(pages)
workflow.Shared.Pages = pages
workflow.Shared.PageMarkdown = pageMarkdown
if workflow.Shared.PreviewMarkdown == "" {
workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow)
}
if workflow.Shared.PreviewHTML == "" {
workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown)
}
output := map[string]any{
"page_markdown": pageMarkdown,
"pages": pages,
"count": len(pages),
}
return officialAccountStepExecution{
Summary: fmt.Sprintf("分页 MD 已生成,共 %d 页。", len(pages)),
TaskStatus: workerTaskStatusDraft,
ArtifactTitle: "公众号分页 MD",
ArtifactType: "page_markdown",
ArtifactStatus: workerArtifactStatusDraft,
ArtifactContent: pageMarkdown,
Output: output,
Logs: []string{
fmt.Sprintf("%s 已按段落结构生成分页 MD。", now.Format("15:04:05")),
fmt.Sprintf("%s 分页结果可直接导出。", now.Format("15:04:05")),
},
}
}
func buildOfficialAccountImagePrompts(workflow *officialAccountWorkflowState) []officialAccountImagePrompt {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
prompts := []officialAccountImagePrompt{
{
Key: "cover",
Section: "封面配图",
Prompt: buildOfficialAccountVisualPrompt(workflow, title, "公众号封面主视觉"),
},
}
for idx, item := range sections {
if idx >= 3 {
break
}
prompts = append(prompts, officialAccountImagePrompt{
Key: fmt.Sprintf("section_%02d", idx+1),
Section: item.Title,
Prompt: buildOfficialAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")),
})
}
return prompts
}
func buildOfficialAccountContentWithPrompts(content string, prompts []officialAccountImagePrompt) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
blocks := []string{content, "", "## 配图提示词"}
for idx, item := range prompts {
blocks = append(blocks, fmt.Sprintf("- IMAGE_PROMPT_%02d [%s] %s", idx+1, firstNonEmpty(item.Section, "段落配图"), item.Prompt))
}
return strings.Join(blocks, "\n")
}
func buildOfficialAccountVisualPrompt(workflow *officialAccountWorkflowState, sectionTitle, context string) string {
keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题")
tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂")
domain := officialAccountBusinessDomainLabel(workflow.Form.BusinessDomain)
return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark", domain, firstNonEmpty(sectionTitle, keyword), firstNonEmpty(context, tone))
}
func buildOfficialAccountImageArtifact(refs []officialAccountImageRef) string {
lines := []string{"# 图片结果", ""}
for idx, item := range refs {
lines = append(lines,
fmt.Sprintf("%d. %s", idx+1, firstNonEmpty(item.Section, item.Key)),
fmt.Sprintf("- 提示词:%s", item.Prompt),
fmt.Sprintf("- 图片地址:%s", item.URL),
fmt.Sprintf("- 生成模型:%s / %s", firstNonEmpty(item.RouteProvider, "unknown"), firstNonEmpty(item.RouteModel, "unknown")),
"",
)
}
return strings.Join(lines, "\n")
}
func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflowState, prompts []officialAccountImagePrompt, user *model.User) (*config.RouteConfig, []officialAccountImageRef, *web.AppError) {
route, err := config.GetRoute("image_gen")
if err != nil {
return nil, nil, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置")
}
if route.Category != "image" || strings.TrimSpace(route.FullURL) == "" || strings.TrimSpace(route.Model) == "" {
return nil, nil, web.NewLLMNotConfigured("图片生成路由配置不完整")
}
client := &http.Client{Timeout: 240 * time.Second}
refs := make([]officialAccountImageRef, 0, len(prompts))
for idx, item := range prompts {
imageSize := officialAccountImageSlotSize(idx)
requestSize := officialAccountImageRequestSize(imageSize)
started := time.Now()
imageURL, reqErr := requestOfficialAccountImage(client, route, item.Prompt, requestSize)
latencyMs := int(time.Since(started) / time.Millisecond)
ai.LogCall(ai.LogEntry{
UserID: user.ID,
Capability: ai.CapabilityImageGen,
Provider: route.Provider,
RouteID: route.RouteID,
Model: route.Model,
Success: reqErr == nil,
ErrorMessage: errorMessage(reqErr),
LatencyMs: latencyMs,
})
if reqErr != nil {
return nil, nil, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error()))
}
if strings.HasPrefix(strings.TrimSpace(imageURL), "data:image/") {
imageURL, reqErr = persistOfficialAccountDataURL(taskID, item.Key, imageURL)
if reqErr != nil {
return nil, nil, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error()))
}
}
refs = append(refs, officialAccountImageRef{
Key: item.Key,
Section: item.Section,
Prompt: item.Prompt,
URL: imageURL,
ImageSize: imageSize,
RouteID: route.RouteID,
RouteModel: route.Model,
RouteProvider: route.Provider,
})
}
return route, refs, nil
}
func requestOfficialAccountImage(client *http.Client, route *config.RouteConfig, prompt, size string) (string, error) {
payload := map[string]any{
"model": route.Model,
"prompt": strings.TrimSpace(prompt),
"n": 1,
"size": size,
}
imageURL, err := postOfficialAccountImageRequest(client, route, payload)
if err == nil {
return imageURL, nil
}
payload["response_format"] = "b64_json"
return postOfficialAccountImageRequest(client, route, payload)
}
func postOfficialAccountImageRequest(client *http.Client, route *config.RouteConfig, payload map[string]any) (string, error) {
body, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, route.FullURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if strings.TrimSpace(route.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(route.APIKey))
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateOfficialAccountErrorBody(string(raw)))
}
var out struct {
Data []struct {
URL string `json:"url"`
B64JSON string `json:"b64_json"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("图片结果解析失败:%w", err)
}
if len(out.Data) == 0 {
return "", fmt.Errorf("图片接口未返回 data")
}
first := out.Data[0]
if strings.TrimSpace(first.URL) != "" {
return strings.TrimSpace(first.URL), nil
}
if strings.TrimSpace(first.B64JSON) != "" {
return "data:image/png;base64," + strings.TrimSpace(first.B64JSON), nil
}
return "", fmt.Errorf("图片接口未返回 url 或 b64_json")
}
func officialAccountImageSlotSize(index int) string {
if index <= 0 {
return "landscape_16_9"
}
return "landscape_4_3"
}
func officialAccountImageRequestSize(size string) string {
switch strings.TrimSpace(size) {
case "landscape_16_9":
return "1536x864"
case "landscape_4_3":
return "1536x1152"
case "portrait_16_9":
return "864x1536"
case "portrait_4_3":
return "1152x1536"
case "square_hd":
return "1536x1536"
default:
return "1024x1024"
}
}
func truncateOfficialAccountErrorBody(raw string) string {
raw = strings.TrimSpace(raw)
if len(raw) <= 240 {
return raw
}
return raw[:240] + "..."
}
func errorMessage(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func buildOfficialAccountPreviewMarkdown(workflow *officialAccountWorkflowState) string {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
refs := workflow.Shared.ImageRefs
lines := []string{fmt.Sprintf("# %s", title), ""}
if len(refs) > 0 {
lines = append(lines, fmt.Sprintf("![封面配图](%s)", refs[0].URL), "")
}
for idx, item := range sections {
lines = append(lines, fmt.Sprintf("## %s", item.Title))
lines = append(lines, item.Content...)
lines = append(lines, "")
if idx+1 < len(refs) {
lines = append(lines, fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL), "")
}
}
if len(sections) == 0 {
lines = append(lines, workflow.Shared.Content)
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officialAccountPageChunk {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
sections := extractOfficialAccountSections(workflow.Shared.Content)
refs := workflow.Shared.ImageRefs
pages := make([]officialAccountPageChunk, 0, len(sections)+1)
intro := []string{fmt.Sprintf("# %s", title)}
if len(refs) > 0 {
intro = append(intro, "", fmt.Sprintf("![封面配图](%s)", refs[0].URL))
}
pages = append(pages, officialAccountPageChunk{
Index: 1,
Title: title,
Content: strings.Join(intro, "\n"),
})
for idx, item := range sections {
lines := []string{fmt.Sprintf("## %s", item.Title)}
lines = append(lines, item.Content...)
if idx+1 < len(refs) {
lines = append(lines, "", fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL))
}
pages = append(pages, officialAccountPageChunk{
Index: idx + 2,
Title: item.Title,
Content: strings.TrimSpace(strings.Join(lines, "\n")),
})
}
return pages
}
func buildOfficialAccountPageMarkdown(pages []officialAccountPageChunk) string {
chunks := make([]string, 0, len(pages))
for _, item := range pages {
chunks = append(chunks, fmt.Sprintf("<!-- PAGE %d: %s -->\n%s", item.Index, item.Title, item.Content))
}
return strings.Join(chunks, "\n\n---\n\n")
}
func extractOfficialAccountSections(content string) []officialAccountSection {
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
sections := make([]officialAccountSection, 0, 6)
current := officialAccountSection{}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "# ") {
continue
}
if strings.HasPrefix(line, "## ") {
if current.Title != "" || len(current.Content) > 0 {
sections = append(sections, current)
}
current = officialAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))}
continue
}
if current.Title == "" && line == "" {
continue
}
current.Content = append(current.Content, raw)
}
if current.Title != "" || len(current.Content) > 0 {
sections = append(sections, current)
}
for idx := range sections {
if sections[idx].Title == "" {
sections[idx].Title = fmt.Sprintf("内容页 %d", idx+1)
}
sections[idx].Content = trimEmptyLines(sections[idx].Content)
}
return sections
}
func trimEmptyLines(lines []string) []string {
start := 0
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
start++
}
end := len(lines)
for end > start && strings.TrimSpace(lines[end-1]) == "" {
end--
}
return append([]string(nil), lines[start:end]...)
}
func renderOfficialAccountHTML(markdown string) string {
var builder strings.Builder
builder.WriteString(`<article class="oa-preview">`)
listOpen := false
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
if listOpen {
builder.WriteString(`</ul>`)
listOpen = false
}
continue
}
if strings.HasPrefix(line, "![") && strings.Contains(line, "](") && strings.HasSuffix(line, ")") {
if listOpen {
builder.WriteString(`</ul>`)
listOpen = false
}
alt, src := parseOfficialAccountImageMarkdown(line)
builder.WriteString(fmt.Sprintf(`<figure class="oa-preview-figure"><img src="%s" alt="%s" /><figcaption>%s</figcaption></figure>`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(alt)))
continue
}
if strings.HasPrefix(line, "# ") {
if listOpen {
builder.WriteString(`</ul>`)
listOpen = false
}
builder.WriteString(fmt.Sprintf(`<h1>%s</h1>`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "# ")))))
continue
}
if strings.HasPrefix(line, "## ") {
if listOpen {
builder.WriteString(`</ul>`)
listOpen = false
}
builder.WriteString(fmt.Sprintf(`<h2>%s</h2>`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "## ")))))
continue
}
if strings.HasPrefix(line, "> ") {
if listOpen {
builder.WriteString(`</ul>`)
listOpen = false
}
builder.WriteString(fmt.Sprintf(`<blockquote>%s</blockquote>`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "> ")))))
continue
}
if strings.HasPrefix(line, "- ") {
if !listOpen {
builder.WriteString(`<ul>`)
listOpen = true
}
builder.WriteString(fmt.Sprintf(`<li>%s</li>`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "- ")))))
continue
}
if listOpen {
builder.WriteString(`</ul>`)
listOpen = false
}
builder.WriteString(fmt.Sprintf(`<p>%s</p>`, html.EscapeString(line)))
}
if listOpen {
builder.WriteString(`</ul>`)
}
builder.WriteString(`</article>`)
return builder.String()
}
func parseOfficialAccountImageMarkdown(line string) (string, string) {
startAlt := strings.Index(line, "![")
mid := strings.Index(line, "](")
end := strings.LastIndex(line, ")")
if startAlt < 0 || mid < 0 || end < 0 || end <= mid+2 {
return "配图", ""
}
alt := line[startAlt+2 : mid]
src := line[mid+2 : end]
return firstNonEmpty(strings.TrimSpace(alt), "配图"), strings.TrimSpace(src)
}
func ExportOfficialAccountDocument(c *gin.Context) {
user := middleware.CurrentUser(c)
task, ok := loadAccessibleWorkerTask(c, user)
if !ok {
return
}
if task.SpecialistKey != officialAccountSpecialistKey {
web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手"))
return
}
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
article, err := ensureOfficialAccountArticle(task, workflow)
if err != nil {
web.Fail(c, web.NewBadRequest("读取公众号文章状态失败"))
return
}
syncWorkflowFromOfficialAccountArticle(&workflow, article)
format := strings.TrimSpace(strings.ToLower(c.DefaultQuery("format", "preview_md")))
filename := "official-account-export.md"
contentType := "text/markdown; charset=utf-8"
body := workflow.Shared.PreviewMarkdown
switch format {
case "preview_md":
if strings.TrimSpace(body) == "" {
body = buildOfficialAccountPreviewMarkdown(&workflow)
}
filename = "official-account-preview.md"
case "preview_html":
contentType = "text/html; charset=utf-8"
body = workflow.Shared.PreviewHTML
if strings.TrimSpace(body) == "" {
body = renderOfficialAccountHTML(buildOfficialAccountPreviewMarkdown(&workflow))
}
filename = "official-account-preview.html"
case "pagemd":
body = workflow.Shared.PageMarkdown
if strings.TrimSpace(body) == "" {
body = buildOfficialAccountPageMarkdown(buildOfficialAccountPages(&workflow))
}
filename = "official-account-pagemd.md"
default:
web.Fail(c, web.NewBadRequest("导出格式不支持"))
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename)))
c.Data(200, contentType, []byte(body))
}
@@ -0,0 +1,175 @@
package api
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/config"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
func officialAccountGeneratedImageDir() string {
cfg := config.Load()
return filepath.Join(filepath.Dir(cfg.DBPath), "official_account_generated_images")
}
func normalizeOfficialAccountMediaForResponse(task *model.WorkerTask, workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle) error {
if workflow == nil || article == nil || task == nil {
return nil
}
refs, replacements, changed, err := persistOfficialAccountImageRefs(task.ID, workflow.Shared.ImageRefs)
if err != nil {
return err
}
if !changed {
return nil
}
workflow.Shared.ImageRefs = refs
workflow.Shared.PreviewMarkdown = replaceOfficialAccountURLs(workflow.Shared.PreviewMarkdown, replacements)
workflow.Shared.PreviewHTML = replaceOfficialAccountURLs(workflow.Shared.PreviewHTML, replacements)
workflow.Shared.PageMarkdown = replaceOfficialAccountURLs(workflow.Shared.PageMarkdown, replacements)
for idx := range workflow.Shared.Pages {
workflow.Shared.Pages[idx].Content = replaceOfficialAccountURLs(workflow.Shared.Pages[idx].Content, replacements)
}
syncOfficialAccountArticleFromWorkflow(article, *workflow)
task.ContextJSON = marshalOfficialAccountWorkflow(*workflow)
if err := store.DB.Save(article).Error; err != nil {
return err
}
if err := store.DB.Save(task).Error; err != nil {
return err
}
return nil
}
func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef) ([]officialAccountImageRef, map[string]string, bool, error) {
if len(refs) == 0 {
return refs, map[string]string{}, false, nil
}
next := make([]officialAccountImageRef, len(refs))
replacements := map[string]string{}
changed := false
for idx, item := range refs {
next[idx] = item
if !strings.HasPrefix(strings.TrimSpace(item.URL), "data:image/") {
continue
}
localURL, err := persistOfficialAccountDataURL(taskID, item.Key, item.URL)
if err != nil {
return refs, nil, false, err
}
replacements[item.URL] = localURL
next[idx].URL = localURL
changed = true
}
return next, replacements, changed, nil
}
func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error) {
parts := strings.SplitN(strings.TrimSpace(raw), ",", 2)
if len(parts) != 2 {
return "", fmt.Errorf("图片数据格式错误")
}
header := parts[0]
body := parts[1]
if !strings.Contains(header, ";base64") {
return "", fmt.Errorf("图片数据缺少 base64 头")
}
ext := ".png"
switch {
case strings.HasPrefix(header, "data:image/jpeg"):
ext = ".jpg"
case strings.HasPrefix(header, "data:image/webp"):
ext = ".webp"
case strings.HasPrefix(header, "data:image/gif"):
ext = ".gif"
}
payload, err := base64.StdEncoding.DecodeString(body)
if err != nil {
return "", err
}
dir := officialAccountGeneratedImageDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
hash := sha1.Sum(payload)
filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeOfficialAccountImageKey(key), hash[:6], ext)
fullPath := filepath.Join(dir, filename)
if _, err := os.Stat(fullPath); err != nil {
if writeErr := os.WriteFile(fullPath, payload, 0o644); writeErr != nil {
return "", writeErr
}
}
return "/api/official-account/generated-images/" + filename, nil
}
func sanitizeOfficialAccountImageKey(key string) string {
trimmed := strings.TrimSpace(strings.ToLower(key))
trimmed = strings.ReplaceAll(trimmed, " ", "_")
trimmed = strings.ReplaceAll(trimmed, "/", "_")
trimmed = strings.ReplaceAll(trimmed, "\\", "_")
if trimmed == "" {
return "image"
}
return trimmed
}
func replaceOfficialAccountURLs(content string, replacements map[string]string) string {
next := content
for oldURL, newURL := range replacements {
next = strings.ReplaceAll(next, oldURL, newURL)
}
return next
}
func ServeOfficialAccountGeneratedImage(c *gin.Context) {
filename := filepath.Base(strings.TrimSpace(c.Param("filename")))
if filename == "." || filename == "" {
web.Fail(c, web.NewNotFoundError("图片不存在"))
return
}
fullPath := filepath.Join(officialAccountGeneratedImageDir(), filename)
if _, err := os.Stat(fullPath); err != nil {
web.Fail(c, web.NewNotFoundError("图片不存在"))
return
}
c.File(fullPath)
}
func compactOfficialAccountRunsForResponse(items []model.WorkerRun) []model.WorkerRun {
next := make([]model.WorkerRun, len(items))
for idx, item := range items {
next[idx] = item
if len(item.OutputJSON) > 8192 || strings.Contains(item.OutputJSON, "data:image/") {
next[idx].OutputJSON = ""
}
if len(item.InputJSON) > 4096 {
next[idx].InputJSON = ""
}
}
return next
}
func compactOfficialAccountArtifactsForResponse(items []model.WorkerArtifact) []model.WorkerArtifact {
next := make([]model.WorkerArtifact, len(items))
for idx, item := range items {
next[idx] = item
if len(item.ContentJSON) > 8192 || strings.Contains(item.ContentJSON, "data:image/") {
next[idx].ContentJSON = ""
}
if len(item.ContentText) > 20000 {
next[idx].ContentText = item.ContentText[:20000] + "\n\n[内容过长,已在画布接口中省略]"
}
}
return next
}
@@ -20,11 +20,15 @@ import (
const (
officialAccountSpecialistKey = "wechat-official-account"
officialAccountStepKeyConfig = "task_configuration"
officialAccountStepKeyTopic = "topic_recommendation"
officialAccountStepKeyConfig = "task_configuration"
officialAccountStepKeyTopic = "topic_recommendation"
officialAccountStepKeyTitle = "title_generation"
officialAccountStepKeyOutline = "outline_generation"
officialAccountStepKeyContent = "content_creation"
officialAccountStepKeyImagePrompts = "image_prompt_generation"
officialAccountStepKeyImages = "image_generation"
officialAccountStepKeyPreview = "preview_export"
officialAccountStepKeyPageMD = "page_markdown"
officialAccountStepStatusPending = "pending"
officialAccountStepStatusRunning = "running"
@@ -33,11 +37,12 @@ const (
)
type officialAccountTaskCreateReq struct {
Keyword string `json:"keyword"`
Audience string `json:"audience"`
Goal string `json:"goal"`
Tone string `json:"tone"`
Requirements string `json:"requirements"`
BusinessDomain string `json:"business_domain"`
Keyword string `json:"keyword"`
Audience string `json:"audience"`
Goal string `json:"goal"`
Tone string `json:"tone"`
Requirements string `json:"requirements"`
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
@@ -47,10 +52,13 @@ type officialAccountStepReq struct {
Topic string `json:"topic"`
Title string `json:"title"`
Outline string `json:"outline"`
Content string `json:"content"`
ContentWithPrompts string `json:"content_with_prompts"`
Requirements string `json:"requirements"`
}
type officialAccountTaskUpdateReq struct {
BusinessDomain string `json:"business_domain"`
Keyword string `json:"keyword"`
Audience string `json:"audience"`
Goal string `json:"goal"`
@@ -70,11 +78,12 @@ type officialAccountWorkflowState struct {
}
type officialAccountForm struct {
Keyword string `json:"keyword"`
Audience string `json:"audience"`
Goal string `json:"goal"`
Tone string `json:"tone"`
Requirements string `json:"requirements"`
BusinessDomain string `json:"business_domain"`
Keyword string `json:"keyword"`
Audience string `json:"audience"`
Goal string `json:"goal"`
Tone string `json:"tone"`
Requirements string `json:"requirements"`
OutlineStyle string `json:"outline_style"`
OutlineWords int `json:"outline_words"`
ContentTargetWords int `json:"content_target_words"`
@@ -87,6 +96,13 @@ type officialAccountSharedState struct {
SelectedTitle string `json:"selected_title"`
Outline string `json:"outline"`
Content string `json:"content"`
ContentWithPrompts string `json:"content_with_prompts"`
ImagePrompts []officialAccountImagePrompt `json:"image_prompts"`
ImageRefs []officialAccountImageRef `json:"image_refs"`
PreviewMarkdown string `json:"preview_markdown"`
PreviewHTML string `json:"preview_html"`
PageMarkdown string `json:"page_markdown"`
Pages []officialAccountPageChunk `json:"pages"`
}
type officialAccountStepState struct {
@@ -108,6 +124,29 @@ type officialAccountTopicCandidate struct {
Heat string `json:"heat"`
}
type officialAccountImagePrompt struct {
Key string `json:"key"`
Section string `json:"section"`
Prompt string `json:"prompt"`
}
type officialAccountImageRef struct {
Key string `json:"key"`
Section string `json:"section"`
Prompt string `json:"prompt"`
URL string `json:"url"`
ImageSize string `json:"image_size"`
RouteID string `json:"route_id,omitempty"`
RouteModel string `json:"route_model,omitempty"`
RouteProvider string `json:"route_provider,omitempty"`
}
type officialAccountPageChunk struct {
Index int `json:"index"`
Title string `json:"title"`
Content string `json:"content"`
}
type officialAccountStepExecution struct {
Summary string
TaskStatus string
@@ -229,6 +268,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
}
syncWorkflowFromOfficialAccountArticle(&oldWorkflow, article)
changed := oldWorkflow.Form.Keyword != req.Keyword ||
oldWorkflow.Form.BusinessDomain != resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) ||
oldWorkflow.Form.Audience != firstNonEmpty(req.Audience, "公众号读者") ||
oldWorkflow.Form.Goal != firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") ||
oldWorkflow.Form.Tone != firstNonEmpty(req.Tone, "专业但好懂") ||
@@ -240,6 +280,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
workflow := oldWorkflow
if changed {
workflow = newOfficialAccountWorkflow(officialAccountTaskCreateReq{
BusinessDomain: req.BusinessDomain,
Keyword: req.Keyword,
Audience: req.Audience,
Goal: req.Goal,
@@ -250,6 +291,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
ContentTargetWords: req.ContentTargetWords,
})
} else {
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
workflow.Form.Keyword = req.Keyword
workflow.Form.Audience = firstNonEmpty(req.Audience, "公众号读者")
workflow.Form.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
@@ -263,6 +305,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
if changed {
task.Title = buildOfficialAccountTaskTitle(req.Keyword)
task.Summary = buildOfficialAccountTaskSummary(officialAccountTaskCreateReq{
BusinessDomain: req.BusinessDomain,
Keyword: req.Keyword,
Audience: req.Audience,
})
@@ -300,6 +343,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
configSummary := fmt.Sprintf("任务配置已更新为「%s」,流程已重置,等待重新执行。", req.Keyword)
outputJSON, _ := json.Marshal(gin.H{
"summary": configSummary,
"business_domain": resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword),
"keyword": req.Keyword,
"audience": firstNonEmpty(req.Audience, "公众号读者"),
"goal": firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"),
@@ -359,6 +403,8 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) {
req.Topic = strings.TrimSpace(req.Topic)
req.Title = strings.TrimSpace(req.Title)
req.Outline = strings.TrimSpace(req.Outline)
req.Content = strings.TrimSpace(req.Content)
req.ContentWithPrompts = strings.TrimSpace(req.ContentWithPrompts)
req.Requirements = strings.TrimSpace(req.Requirements)
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
@@ -398,6 +444,8 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) {
"topic": req.Topic,
"title": req.Title,
"outline": req.Outline,
"content": req.Content,
"content_with_prompts": req.ContentWithPrompts,
"requirements": req.Requirements,
})
outputPayload := cloneOutputMap(execution.Output)
@@ -465,6 +513,10 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) {
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
article, _ := ensureOfficialAccountArticle(task, workflow)
syncWorkflowFromOfficialAccountArticle(&workflow, article)
if err := normalizeOfficialAccountMediaForResponse(&task, &workflow, article); err != nil {
web.Fail(c, web.NewBadRequest("整理公众号图片结果失败"))
return
}
var artifacts []model.WorkerArtifact
if err := store.DB.Where("task_id = ?", task.ID).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil {
@@ -477,6 +529,8 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) {
web.Fail(c, web.NewBadRequest("查询公众号运行记录失败"))
return
}
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
runs = compactOfficialAccountRunsForResponse(runs)
web.OK(c, gin.H{
"task": task,
@@ -493,6 +547,7 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
WorkflowType: "wechat_official_account",
CurrentStep: officialAccountStepKeyTopic,
Form: officialAccountForm{
BusinessDomain: resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword),
Keyword: req.Keyword,
Audience: firstNonEmpty(req.Audience, "公众号读者"),
Goal: firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"),
@@ -506,6 +561,9 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
Shared: officialAccountSharedState{
TopicCandidates: []officialAccountTopicCandidate{},
TitleCandidates: []string{},
ImagePrompts: []officialAccountImagePrompt{},
ImageRefs: []officialAccountImageRef{},
Pages: []officialAccountPageChunk{},
},
}
for _, item := range []struct {
@@ -517,6 +575,10 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
{officialAccountStepKeyTitle, officialAccountStepLabel(officialAccountStepKeyTitle), "等待生成标题"},
{officialAccountStepKeyOutline, officialAccountStepLabel(officialAccountStepKeyOutline), "等待生成提纲"},
{officialAccountStepKeyContent, officialAccountStepLabel(officialAccountStepKeyContent), "等待创作正文"},
{officialAccountStepKeyImagePrompts, officialAccountStepLabel(officialAccountStepKeyImagePrompts), "等待生成配图提示词"},
{officialAccountStepKeyImages, officialAccountStepLabel(officialAccountStepKeyImages), "等待生成图片"},
{officialAccountStepKeyPreview, officialAccountStepLabel(officialAccountStepKeyPreview), "等待生成预览稿"},
{officialAccountStepKeyPageMD, officialAccountStepLabel(officialAccountStepKeyPageMD), "等待生成分页 MD"},
} {
workflow.Steps[item.key] = &officialAccountStepState{
Key: item.key,
@@ -563,6 +625,9 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState {
if workflow.Form.Audience == "" {
workflow.Form.Audience = "公众号读者"
}
if workflow.Form.BusinessDomain == "" {
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain("", workflow.Form.Keyword)
}
if workflow.Form.Goal == "" {
workflow.Form.Goal = "输出一篇可发布的公众号文章"
}
@@ -600,6 +665,15 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState {
if workflow.Shared.TitleCandidates == nil {
workflow.Shared.TitleCandidates = []string{}
}
if workflow.Shared.ImagePrompts == nil {
workflow.Shared.ImagePrompts = []officialAccountImagePrompt{}
}
if workflow.Shared.ImageRefs == nil {
workflow.Shared.ImageRefs = []officialAccountImageRef{}
}
if workflow.Shared.Pages == nil {
workflow.Shared.Pages = []officialAccountPageChunk{}
}
if workflow.CurrentStep == "" {
workflow.CurrentStep = officialAccountStepKeyTopic
}
@@ -616,7 +690,7 @@ func marshalOfficialAccountWorkflow(workflow officialAccountWorkflowState) strin
func isOfficialAccountStep(stepKey string) bool {
switch stepKey {
case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent:
case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent, officialAccountStepKeyImagePrompts, officialAccountStepKeyImages, officialAccountStepKeyPreview, officialAccountStepKeyPageMD:
return true
default:
return false
@@ -633,6 +707,14 @@ func officialAccountStepLabel(stepKey string) string {
return "提纲生成"
case officialAccountStepKeyContent:
return "正文创作"
case officialAccountStepKeyImagePrompts:
return "配图提示词"
case officialAccountStepKeyImages:
return "图片生成"
case officialAccountStepKeyPreview:
return "预览与导出"
case officialAccountStepKeyPageMD:
return "分页 MD"
default:
return "工作流步骤"
}
@@ -646,8 +728,16 @@ func officialAccountNextStep(stepKey string) string {
return officialAccountStepKeyOutline
case officialAccountStepKeyOutline:
return officialAccountStepKeyContent
case officialAccountStepKeyContent:
return officialAccountStepKeyImagePrompts
case officialAccountStepKeyImagePrompts:
return officialAccountStepKeyImages
case officialAccountStepKeyImages:
return officialAccountStepKeyPreview
case officialAccountStepKeyPreview:
return officialAccountStepKeyPageMD
default:
return officialAccountStepKeyContent
return officialAccountStepKeyPageMD
}
}
@@ -724,13 +814,43 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta
return officialAccountStepExecution{}, web.NewBadRequest("请先生成提纲,或手动输入提纲")
}
return executeOfficialAccountContentStep(workflow, article, user, now), nil
case officialAccountStepKeyImagePrompts:
if req.Content != "" {
workflow.Shared.Content = req.Content
}
if workflow.Shared.Content == "" {
return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成配图提示词")
}
return executeOfficialAccountImagePromptStep(workflow, article, user, now), nil
case officialAccountStepKeyImages:
if req.ContentWithPrompts != "" {
workflow.Shared.ContentWithPrompts = req.ContentWithPrompts
}
if len(workflow.Shared.ImagePrompts) == 0 && workflow.Shared.ContentWithPrompts == "" {
return officialAccountStepExecution{}, web.NewBadRequest("请先生成配图提示词")
}
execution, appErr := executeOfficialAccountImageGenerationStep(workflow, article, user, now)
if appErr != nil {
return officialAccountStepExecution{}, appErr
}
return execution, nil
case officialAccountStepKeyPreview:
if workflow.Shared.Content == "" {
return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成预览稿")
}
return executeOfficialAccountPreviewStep(workflow, article, user, now), nil
case officialAccountStepKeyPageMD:
if workflow.Shared.Content == "" {
return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成分页 MD")
}
return executeOfficialAccountPageMarkdownStep(workflow, article, user, now), nil
default:
return officialAccountStepExecution{}, web.NewBadRequest("工作流步骤不存在")
}
}
func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(false)
hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(workflow.Form, false)
hotspots = sortOfficialAccountHotspots(hotspots)
candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots)
_ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates)
@@ -1123,8 +1243,20 @@ func buildOfficialAccountTaskSummary(req officialAccountTaskCreateReq) string {
}
func buildOfficialAccountTaskSummaryFromWorkflow(workflow officialAccountWorkflowState) string {
if workflow.Shared.Content != "" {
return "正文草稿已生成,等待人工校对。"
if workflow.Shared.PageMarkdown != "" {
return "分页 MD 已生成,可直接预览与导出。"
}
if workflow.Shared.PreviewHTML != "" {
return "图文预览稿已生成,可检查排版并导出。"
}
if len(workflow.Shared.ImageRefs) > 0 {
return fmt.Sprintf("已生成 %d 张配图,可继续预览与导出。", len(workflow.Shared.ImageRefs))
}
if len(workflow.Shared.ImagePrompts) > 0 || workflow.Shared.ContentWithPrompts != "" {
return "配图提示词已生成,可继续生成图片。"
}
if workflow.Shared.Content != "" {
return "正文草稿已生成,可继续补齐配图与预览。"
}
if workflow.Shared.Outline != "" {
return "提纲已生成,可继续创作正文。"
@@ -1225,4 +1357,3 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
@@ -51,6 +51,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask)
r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), ExecuteOfficialAccountWorkflowStep)
r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), ExportOfficialAccountDocument)
r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), ServeOfficialAccountGeneratedImage)
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
@@ -87,6 +87,10 @@ func GetWorkerTaskDetail(c *gin.Context) {
web.Fail(c, web.NewBadRequest("查询运行记录失败"))
return
}
if task.SpecialistKey == officialAccountSpecialistKey {
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
runs = compactOfficialAccountRunsForResponse(runs)
}
web.OK(c, gin.H{
"task": task,
@@ -2,7 +2,7 @@ package model
import "time"
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文显式传递。
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。
type OfficialAccountArticle struct {
ID uint `gorm:"primaryKey" json:"id"`
TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"`
@@ -24,6 +24,13 @@ type OfficialAccountArticle struct {
Outline string `gorm:"type:text" json:"outline"`
ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"`
Content string `gorm:"type:text" json:"content"`
ContentWithPrompts string `gorm:"type:text" json:"content_with_prompts"`
ImagePromptsJSON string `gorm:"type:text" json:"image_prompts_json"`
ImageRefsJSON string `gorm:"type:text" json:"image_refs_json"`
PreviewMarkdown string `gorm:"type:text" json:"preview_markdown"`
PreviewHTML string `gorm:"type:text" json:"preview_html"`
PageMarkdown string `gorm:"type:text" json:"page_markdown"`
PagesJSON string `gorm:"type:text" json:"pages_json"`
Status string `gorm:"size:32;not null;default:draft;index" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`