公众号助手新增图片
This commit is contained in:
@@ -29,3 +29,4 @@ docs/09_Research/工业AI观察员/raw/
|
|||||||
# ===== 操作系统 =====
|
# ===== 操作系统 =====
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.ollama_models/
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
|
CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点)
|
||||||
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
|
CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点)
|
||||||
|
CapabilityImageGen = "image_gen" // 文生图(按次扣点)
|
||||||
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
|
CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计)
|
||||||
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
|
CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计)
|
||||||
)
|
)
|
||||||
@@ -25,6 +26,7 @@ const (
|
|||||||
var CapabilityCredits = map[string]int{
|
var CapabilityCredits = map[string]int{
|
||||||
CapabilityAIChat: 1,
|
CapabilityAIChat: 1,
|
||||||
CapabilityTextGen: 1,
|
CapabilityTextGen: 1,
|
||||||
|
CapabilityImageGen: 1,
|
||||||
CapabilityEmbed: 0,
|
CapabilityEmbed: 0,
|
||||||
CapabilityEssayGrade: 0,
|
CapabilityEssayGrade: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,28 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta
|
|||||||
workflow.Shared.SelectedTitle = article.SelectedTitle
|
workflow.Shared.SelectedTitle = article.SelectedTitle
|
||||||
workflow.Shared.Outline = article.Outline
|
workflow.Shared.Outline = article.Outline
|
||||||
workflow.Shared.Content = article.Content
|
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) {
|
func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticle, workflow officialAccountWorkflowState) {
|
||||||
@@ -92,13 +114,34 @@ func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticl
|
|||||||
article.SelectedTitle = workflow.Shared.SelectedTitle
|
article.SelectedTitle = workflow.Shared.SelectedTitle
|
||||||
article.Outline = workflow.Shared.Outline
|
article.Outline = workflow.Shared.Outline
|
||||||
article.Content = workflow.Shared.Content
|
article.Content = workflow.Shared.Content
|
||||||
|
article.ContentWithPrompts = workflow.Shared.ContentWithPrompts
|
||||||
if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil {
|
if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil {
|
||||||
article.TopicCandidatesJSON = string(data)
|
article.TopicCandidatesJSON = string(data)
|
||||||
}
|
}
|
||||||
if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil {
|
if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil {
|
||||||
article.TitleCandidatesJSON = string(data)
|
article.TitleCandidatesJSON = string(data)
|
||||||
}
|
}
|
||||||
if workflow.Shared.Content != "" {
|
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"
|
article.Status = "content_ready"
|
||||||
} else if workflow.Shared.Outline != "" {
|
} else if workflow.Shared.Outline != "" {
|
||||||
article.Status = "outline_ready"
|
article.Status = "outline_ready"
|
||||||
@@ -132,6 +175,13 @@ func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req offi
|
|||||||
article.Outline = ""
|
article.Outline = ""
|
||||||
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
|
article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords)
|
||||||
article.Content = ""
|
article.Content = ""
|
||||||
|
article.ContentWithPrompts = ""
|
||||||
|
article.ImagePromptsJSON = "[]"
|
||||||
|
article.ImageRefsJSON = "[]"
|
||||||
|
article.PreviewMarkdown = ""
|
||||||
|
article.PreviewHTML = ""
|
||||||
|
article.PageMarkdown = ""
|
||||||
|
article.PagesJSON = "[]"
|
||||||
article.Status = "draft"
|
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("", 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("", 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("", 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("", 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.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, "
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ const (
|
|||||||
officialAccountStepKeyTitle = "title_generation"
|
officialAccountStepKeyTitle = "title_generation"
|
||||||
officialAccountStepKeyOutline = "outline_generation"
|
officialAccountStepKeyOutline = "outline_generation"
|
||||||
officialAccountStepKeyContent = "content_creation"
|
officialAccountStepKeyContent = "content_creation"
|
||||||
|
officialAccountStepKeyImagePrompts = "image_prompt_generation"
|
||||||
|
officialAccountStepKeyImages = "image_generation"
|
||||||
|
officialAccountStepKeyPreview = "preview_export"
|
||||||
|
officialAccountStepKeyPageMD = "page_markdown"
|
||||||
|
|
||||||
officialAccountStepStatusPending = "pending"
|
officialAccountStepStatusPending = "pending"
|
||||||
officialAccountStepStatusRunning = "running"
|
officialAccountStepStatusRunning = "running"
|
||||||
@@ -33,6 +37,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type officialAccountTaskCreateReq struct {
|
type officialAccountTaskCreateReq struct {
|
||||||
|
BusinessDomain string `json:"business_domain"`
|
||||||
Keyword string `json:"keyword"`
|
Keyword string `json:"keyword"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
@@ -47,10 +52,13 @@ type officialAccountStepReq struct {
|
|||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Outline string `json:"outline"`
|
Outline string `json:"outline"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ContentWithPrompts string `json:"content_with_prompts"`
|
||||||
Requirements string `json:"requirements"`
|
Requirements string `json:"requirements"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type officialAccountTaskUpdateReq struct {
|
type officialAccountTaskUpdateReq struct {
|
||||||
|
BusinessDomain string `json:"business_domain"`
|
||||||
Keyword string `json:"keyword"`
|
Keyword string `json:"keyword"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
@@ -70,6 +78,7 @@ type officialAccountWorkflowState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type officialAccountForm struct {
|
type officialAccountForm struct {
|
||||||
|
BusinessDomain string `json:"business_domain"`
|
||||||
Keyword string `json:"keyword"`
|
Keyword string `json:"keyword"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
@@ -87,6 +96,13 @@ type officialAccountSharedState struct {
|
|||||||
SelectedTitle string `json:"selected_title"`
|
SelectedTitle string `json:"selected_title"`
|
||||||
Outline string `json:"outline"`
|
Outline string `json:"outline"`
|
||||||
Content string `json:"content"`
|
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 {
|
type officialAccountStepState struct {
|
||||||
@@ -108,6 +124,29 @@ type officialAccountTopicCandidate struct {
|
|||||||
Heat string `json:"heat"`
|
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 {
|
type officialAccountStepExecution struct {
|
||||||
Summary string
|
Summary string
|
||||||
TaskStatus string
|
TaskStatus string
|
||||||
@@ -229,6 +268,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
syncWorkflowFromOfficialAccountArticle(&oldWorkflow, article)
|
syncWorkflowFromOfficialAccountArticle(&oldWorkflow, article)
|
||||||
changed := oldWorkflow.Form.Keyword != req.Keyword ||
|
changed := oldWorkflow.Form.Keyword != req.Keyword ||
|
||||||
|
oldWorkflow.Form.BusinessDomain != resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) ||
|
||||||
oldWorkflow.Form.Audience != firstNonEmpty(req.Audience, "公众号读者") ||
|
oldWorkflow.Form.Audience != firstNonEmpty(req.Audience, "公众号读者") ||
|
||||||
oldWorkflow.Form.Goal != firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") ||
|
oldWorkflow.Form.Goal != firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") ||
|
||||||
oldWorkflow.Form.Tone != firstNonEmpty(req.Tone, "专业但好懂") ||
|
oldWorkflow.Form.Tone != firstNonEmpty(req.Tone, "专业但好懂") ||
|
||||||
@@ -240,6 +280,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
|
|||||||
workflow := oldWorkflow
|
workflow := oldWorkflow
|
||||||
if changed {
|
if changed {
|
||||||
workflow = newOfficialAccountWorkflow(officialAccountTaskCreateReq{
|
workflow = newOfficialAccountWorkflow(officialAccountTaskCreateReq{
|
||||||
|
BusinessDomain: req.BusinessDomain,
|
||||||
Keyword: req.Keyword,
|
Keyword: req.Keyword,
|
||||||
Audience: req.Audience,
|
Audience: req.Audience,
|
||||||
Goal: req.Goal,
|
Goal: req.Goal,
|
||||||
@@ -250,6 +291,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
|
|||||||
ContentTargetWords: req.ContentTargetWords,
|
ContentTargetWords: req.ContentTargetWords,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword)
|
||||||
workflow.Form.Keyword = req.Keyword
|
workflow.Form.Keyword = req.Keyword
|
||||||
workflow.Form.Audience = firstNonEmpty(req.Audience, "公众号读者")
|
workflow.Form.Audience = firstNonEmpty(req.Audience, "公众号读者")
|
||||||
workflow.Form.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
|
workflow.Form.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章")
|
||||||
@@ -263,6 +305,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
|
|||||||
if changed {
|
if changed {
|
||||||
task.Title = buildOfficialAccountTaskTitle(req.Keyword)
|
task.Title = buildOfficialAccountTaskTitle(req.Keyword)
|
||||||
task.Summary = buildOfficialAccountTaskSummary(officialAccountTaskCreateReq{
|
task.Summary = buildOfficialAccountTaskSummary(officialAccountTaskCreateReq{
|
||||||
|
BusinessDomain: req.BusinessDomain,
|
||||||
Keyword: req.Keyword,
|
Keyword: req.Keyword,
|
||||||
Audience: req.Audience,
|
Audience: req.Audience,
|
||||||
})
|
})
|
||||||
@@ -300,6 +343,7 @@ func UpdateOfficialAccountTask(c *gin.Context) {
|
|||||||
configSummary := fmt.Sprintf("任务配置已更新为「%s」,流程已重置,等待重新执行。", req.Keyword)
|
configSummary := fmt.Sprintf("任务配置已更新为「%s」,流程已重置,等待重新执行。", req.Keyword)
|
||||||
outputJSON, _ := json.Marshal(gin.H{
|
outputJSON, _ := json.Marshal(gin.H{
|
||||||
"summary": configSummary,
|
"summary": configSummary,
|
||||||
|
"business_domain": resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword),
|
||||||
"keyword": req.Keyword,
|
"keyword": req.Keyword,
|
||||||
"audience": firstNonEmpty(req.Audience, "公众号读者"),
|
"audience": firstNonEmpty(req.Audience, "公众号读者"),
|
||||||
"goal": firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"),
|
"goal": firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"),
|
||||||
@@ -359,6 +403,8 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) {
|
|||||||
req.Topic = strings.TrimSpace(req.Topic)
|
req.Topic = strings.TrimSpace(req.Topic)
|
||||||
req.Title = strings.TrimSpace(req.Title)
|
req.Title = strings.TrimSpace(req.Title)
|
||||||
req.Outline = strings.TrimSpace(req.Outline)
|
req.Outline = strings.TrimSpace(req.Outline)
|
||||||
|
req.Content = strings.TrimSpace(req.Content)
|
||||||
|
req.ContentWithPrompts = strings.TrimSpace(req.ContentWithPrompts)
|
||||||
req.Requirements = strings.TrimSpace(req.Requirements)
|
req.Requirements = strings.TrimSpace(req.Requirements)
|
||||||
|
|
||||||
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
|
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
|
||||||
@@ -398,6 +444,8 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) {
|
|||||||
"topic": req.Topic,
|
"topic": req.Topic,
|
||||||
"title": req.Title,
|
"title": req.Title,
|
||||||
"outline": req.Outline,
|
"outline": req.Outline,
|
||||||
|
"content": req.Content,
|
||||||
|
"content_with_prompts": req.ContentWithPrompts,
|
||||||
"requirements": req.Requirements,
|
"requirements": req.Requirements,
|
||||||
})
|
})
|
||||||
outputPayload := cloneOutputMap(execution.Output)
|
outputPayload := cloneOutputMap(execution.Output)
|
||||||
@@ -465,6 +513,10 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) {
|
|||||||
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
|
workflow := parseOfficialAccountWorkflow(task.ContextJSON)
|
||||||
article, _ := ensureOfficialAccountArticle(task, workflow)
|
article, _ := ensureOfficialAccountArticle(task, workflow)
|
||||||
syncWorkflowFromOfficialAccountArticle(&workflow, article)
|
syncWorkflowFromOfficialAccountArticle(&workflow, article)
|
||||||
|
if err := normalizeOfficialAccountMediaForResponse(&task, &workflow, article); err != nil {
|
||||||
|
web.Fail(c, web.NewBadRequest("整理公众号图片结果失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var artifacts []model.WorkerArtifact
|
var artifacts []model.WorkerArtifact
|
||||||
if err := store.DB.Where("task_id = ?", task.ID).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil {
|
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("查询公众号运行记录失败"))
|
web.Fail(c, web.NewBadRequest("查询公众号运行记录失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
|
||||||
|
runs = compactOfficialAccountRunsForResponse(runs)
|
||||||
|
|
||||||
web.OK(c, gin.H{
|
web.OK(c, gin.H{
|
||||||
"task": task,
|
"task": task,
|
||||||
@@ -493,6 +547,7 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
|
|||||||
WorkflowType: "wechat_official_account",
|
WorkflowType: "wechat_official_account",
|
||||||
CurrentStep: officialAccountStepKeyTopic,
|
CurrentStep: officialAccountStepKeyTopic,
|
||||||
Form: officialAccountForm{
|
Form: officialAccountForm{
|
||||||
|
BusinessDomain: resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword),
|
||||||
Keyword: req.Keyword,
|
Keyword: req.Keyword,
|
||||||
Audience: firstNonEmpty(req.Audience, "公众号读者"),
|
Audience: firstNonEmpty(req.Audience, "公众号读者"),
|
||||||
Goal: firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"),
|
Goal: firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"),
|
||||||
@@ -506,6 +561,9 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
|
|||||||
Shared: officialAccountSharedState{
|
Shared: officialAccountSharedState{
|
||||||
TopicCandidates: []officialAccountTopicCandidate{},
|
TopicCandidates: []officialAccountTopicCandidate{},
|
||||||
TitleCandidates: []string{},
|
TitleCandidates: []string{},
|
||||||
|
ImagePrompts: []officialAccountImagePrompt{},
|
||||||
|
ImageRefs: []officialAccountImageRef{},
|
||||||
|
Pages: []officialAccountPageChunk{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
@@ -517,6 +575,10 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun
|
|||||||
{officialAccountStepKeyTitle, officialAccountStepLabel(officialAccountStepKeyTitle), "等待生成标题"},
|
{officialAccountStepKeyTitle, officialAccountStepLabel(officialAccountStepKeyTitle), "等待生成标题"},
|
||||||
{officialAccountStepKeyOutline, officialAccountStepLabel(officialAccountStepKeyOutline), "等待生成提纲"},
|
{officialAccountStepKeyOutline, officialAccountStepLabel(officialAccountStepKeyOutline), "等待生成提纲"},
|
||||||
{officialAccountStepKeyContent, officialAccountStepLabel(officialAccountStepKeyContent), "等待创作正文"},
|
{officialAccountStepKeyContent, officialAccountStepLabel(officialAccountStepKeyContent), "等待创作正文"},
|
||||||
|
{officialAccountStepKeyImagePrompts, officialAccountStepLabel(officialAccountStepKeyImagePrompts), "等待生成配图提示词"},
|
||||||
|
{officialAccountStepKeyImages, officialAccountStepLabel(officialAccountStepKeyImages), "等待生成图片"},
|
||||||
|
{officialAccountStepKeyPreview, officialAccountStepLabel(officialAccountStepKeyPreview), "等待生成预览稿"},
|
||||||
|
{officialAccountStepKeyPageMD, officialAccountStepLabel(officialAccountStepKeyPageMD), "等待生成分页 MD"},
|
||||||
} {
|
} {
|
||||||
workflow.Steps[item.key] = &officialAccountStepState{
|
workflow.Steps[item.key] = &officialAccountStepState{
|
||||||
Key: item.key,
|
Key: item.key,
|
||||||
@@ -563,6 +625,9 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState {
|
|||||||
if workflow.Form.Audience == "" {
|
if workflow.Form.Audience == "" {
|
||||||
workflow.Form.Audience = "公众号读者"
|
workflow.Form.Audience = "公众号读者"
|
||||||
}
|
}
|
||||||
|
if workflow.Form.BusinessDomain == "" {
|
||||||
|
workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain("", workflow.Form.Keyword)
|
||||||
|
}
|
||||||
if workflow.Form.Goal == "" {
|
if workflow.Form.Goal == "" {
|
||||||
workflow.Form.Goal = "输出一篇可发布的公众号文章"
|
workflow.Form.Goal = "输出一篇可发布的公众号文章"
|
||||||
}
|
}
|
||||||
@@ -600,6 +665,15 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState {
|
|||||||
if workflow.Shared.TitleCandidates == nil {
|
if workflow.Shared.TitleCandidates == nil {
|
||||||
workflow.Shared.TitleCandidates = []string{}
|
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 == "" {
|
if workflow.CurrentStep == "" {
|
||||||
workflow.CurrentStep = officialAccountStepKeyTopic
|
workflow.CurrentStep = officialAccountStepKeyTopic
|
||||||
}
|
}
|
||||||
@@ -616,7 +690,7 @@ func marshalOfficialAccountWorkflow(workflow officialAccountWorkflowState) strin
|
|||||||
|
|
||||||
func isOfficialAccountStep(stepKey string) bool {
|
func isOfficialAccountStep(stepKey string) bool {
|
||||||
switch stepKey {
|
switch stepKey {
|
||||||
case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent:
|
case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent, officialAccountStepKeyImagePrompts, officialAccountStepKeyImages, officialAccountStepKeyPreview, officialAccountStepKeyPageMD:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -633,6 +707,14 @@ func officialAccountStepLabel(stepKey string) string {
|
|||||||
return "提纲生成"
|
return "提纲生成"
|
||||||
case officialAccountStepKeyContent:
|
case officialAccountStepKeyContent:
|
||||||
return "正文创作"
|
return "正文创作"
|
||||||
|
case officialAccountStepKeyImagePrompts:
|
||||||
|
return "配图提示词"
|
||||||
|
case officialAccountStepKeyImages:
|
||||||
|
return "图片生成"
|
||||||
|
case officialAccountStepKeyPreview:
|
||||||
|
return "预览与导出"
|
||||||
|
case officialAccountStepKeyPageMD:
|
||||||
|
return "分页 MD"
|
||||||
default:
|
default:
|
||||||
return "工作流步骤"
|
return "工作流步骤"
|
||||||
}
|
}
|
||||||
@@ -646,8 +728,16 @@ func officialAccountNextStep(stepKey string) string {
|
|||||||
return officialAccountStepKeyOutline
|
return officialAccountStepKeyOutline
|
||||||
case officialAccountStepKeyOutline:
|
case officialAccountStepKeyOutline:
|
||||||
return officialAccountStepKeyContent
|
return officialAccountStepKeyContent
|
||||||
|
case officialAccountStepKeyContent:
|
||||||
|
return officialAccountStepKeyImagePrompts
|
||||||
|
case officialAccountStepKeyImagePrompts:
|
||||||
|
return officialAccountStepKeyImages
|
||||||
|
case officialAccountStepKeyImages:
|
||||||
|
return officialAccountStepKeyPreview
|
||||||
|
case officialAccountStepKeyPreview:
|
||||||
|
return officialAccountStepKeyPageMD
|
||||||
default:
|
default:
|
||||||
return officialAccountStepKeyContent
|
return officialAccountStepKeyPageMD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,13 +814,43 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta
|
|||||||
return officialAccountStepExecution{}, web.NewBadRequest("请先生成提纲,或手动输入提纲")
|
return officialAccountStepExecution{}, web.NewBadRequest("请先生成提纲,或手动输入提纲")
|
||||||
}
|
}
|
||||||
return executeOfficialAccountContentStep(workflow, article, user, now), nil
|
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:
|
default:
|
||||||
return officialAccountStepExecution{}, web.NewBadRequest("工作流步骤不存在")
|
return officialAccountStepExecution{}, web.NewBadRequest("工作流步骤不存在")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution {
|
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)
|
hotspots = sortOfficialAccountHotspots(hotspots)
|
||||||
candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots)
|
candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots)
|
||||||
_ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates)
|
_ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates)
|
||||||
@@ -1123,8 +1243,20 @@ func buildOfficialAccountTaskSummary(req officialAccountTaskCreateReq) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildOfficialAccountTaskSummaryFromWorkflow(workflow officialAccountWorkflowState) string {
|
func buildOfficialAccountTaskSummaryFromWorkflow(workflow officialAccountWorkflowState) string {
|
||||||
|
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 != "" {
|
if workflow.Shared.Content != "" {
|
||||||
return "正文草稿已生成,等待人工校对。"
|
return "正文草稿已生成,可继续补齐配图与预览。"
|
||||||
}
|
}
|
||||||
if workflow.Shared.Outline != "" {
|
if workflow.Shared.Outline != "" {
|
||||||
return "提纲已生成,可继续创作正文。"
|
return "提纲已生成,可继续创作正文。"
|
||||||
@@ -1225,4 +1357,3 @@ func firstNonEmpty(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), GetOfficialAccountWorkflow)
|
||||||
r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), UpdateOfficialAccountTask)
|
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.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", middleware.Auth(cfg), ListConnectors)
|
||||||
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
|
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
|
||||||
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
|
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ func GetWorkerTaskDetail(c *gin.Context) {
|
|||||||
web.Fail(c, web.NewBadRequest("查询运行记录失败"))
|
web.Fail(c, web.NewBadRequest("查询运行记录失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if task.SpecialistKey == officialAccountSpecialistKey {
|
||||||
|
artifacts = compactOfficialAccountArtifactsForResponse(artifacts)
|
||||||
|
runs = compactOfficialAccountRunsForResponse(runs)
|
||||||
|
}
|
||||||
|
|
||||||
web.OK(c, gin.H{
|
web.OK(c, gin.H{
|
||||||
"task": task,
|
"task": task,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package model
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文显式传递。
|
// OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。
|
||||||
type OfficialAccountArticle struct {
|
type OfficialAccountArticle struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"`
|
TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"`
|
||||||
@@ -24,6 +24,13 @@ type OfficialAccountArticle struct {
|
|||||||
Outline string `gorm:"type:text" json:"outline"`
|
Outline string `gorm:"type:text" json:"outline"`
|
||||||
ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"`
|
ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"`
|
||||||
Content string `gorm:"type:text" json:"content"`
|
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"`
|
Status string `gorm:"size:32;not null;default:draft;index" json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -17,3 +17,11 @@ export function executeOfficialAccountWorkflowStep(taskId, stepKey, data) {
|
|||||||
timeout: 0,
|
timeout: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportOfficialAccountDocument(taskId, format) {
|
||||||
|
return http.get(`/official-account/tasks/${taskId}/export`, {
|
||||||
|
params: { format },
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -330,6 +330,82 @@
|
|||||||
<pre class="content-preview">{{ workflow.shared.content || '还没有生成正文草稿。' }}</pre>
|
<pre class="content-preview">{{ workflow.shared.content || '还没有生成正文草稿。' }}</pre>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="id === stepKeyImagePrompts">
|
||||||
|
<div class="inline-meta">当前已提炼 {{ (workflow.shared.image_prompts || []).length }} 条配图提示词</div>
|
||||||
|
<div v-if="(workflow.shared.image_prompts || []).length" class="prompt-list">
|
||||||
|
<div v-for="(item, promptIndex) in workflow.shared.image_prompts || []" :key="item.key" class="prompt-card">
|
||||||
|
<div class="prompt-header">
|
||||||
|
<div>
|
||||||
|
<div class="prompt-badge">{{ getPromptSlotLabel(item, promptIndex) }}</div>
|
||||||
|
<div class="prompt-title">{{ item.section || getPromptSlotLabel(item, promptIndex) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-chip">用于配图</div>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-meta">用途:{{ getPromptPurpose(item, promptIndex) }}</div>
|
||||||
|
<div class="prompt-body">{{ item.prompt }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-text">当前还没有生成配图提示词。</div>
|
||||||
|
<div class="node-panel-title">原始标记稿</div>
|
||||||
|
<div class="inline-meta">下面这段是给系统继续传递用的结构化正文,不是给人直接阅读的最终文案。</div>
|
||||||
|
<pre class="content-preview prompt-raw-preview">{{ workflow.shared.content_with_prompts || '还没有生成带提示词的正文稿。' }}</pre>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="id === stepKeyImages">
|
||||||
|
<div class="node-panel-title">图片结果</div>
|
||||||
|
<div class="inline-meta">当前已生成 {{ (workflow.shared.image_refs || []).length }} 张图片</div>
|
||||||
|
<div v-if="(workflow.shared.image_refs || []).length" class="image-grid">
|
||||||
|
<div v-for="item in workflow.shared.image_refs || []" :key="item.key" class="image-card">
|
||||||
|
<img class="image-thumb" :src="item.url" :alt="item.section || item.key" />
|
||||||
|
<div class="image-section">{{ item.section || item.key }}</div>
|
||||||
|
<div class="image-prompt">{{ item.prompt }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-text">当前还没有图片结果。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="id === stepKeyPreview">
|
||||||
|
<div class="node-panel-title">预览与导出</div>
|
||||||
|
<div class="action-row action-row-start">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:loading="exportingFormat === 'preview_md'"
|
||||||
|
@click.stop="downloadExport('preview_md', 'official-account-preview.md')"
|
||||||
|
>
|
||||||
|
导出预览 MD
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:loading="exportingFormat === 'preview_html'"
|
||||||
|
@click.stop="downloadExport('preview_html', 'official-account-preview.html')"
|
||||||
|
>
|
||||||
|
导出预览 HTML
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="workflow.shared.preview_html" class="preview-render" v-html="workflow.shared.preview_html" />
|
||||||
|
<div v-else class="empty-text">当前还没有生成预览稿。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="id === stepKeyPageMD">
|
||||||
|
<div class="node-panel-title">分页 MD</div>
|
||||||
|
<div class="action-row action-row-start">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:loading="exportingFormat === 'pagemd'"
|
||||||
|
@click.stop="downloadExport('pagemd', 'official-account-pagemd.md')"
|
||||||
|
>
|
||||||
|
导出分页 MD
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="inline-meta">当前共 {{ (workflow.shared.pages || []).length }} 页</div>
|
||||||
|
<div v-if="(workflow.shared.pages || []).length" class="page-chip-list">
|
||||||
|
<span v-for="item in workflow.shared.pages || []" :key="`${item.index}-${item.title}`" class="page-chip">
|
||||||
|
第 {{ item.index }} 页 · {{ item.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre class="content-preview pagemd-preview">{{ workflow.shared.page_markdown || '还没有生成分页 MD。' }}</pre>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="node-panel-title">节点输出</div>
|
<div class="node-panel-title">节点输出</div>
|
||||||
<div v-if="getOutputRows(id).length" class="output-list">
|
<div v-if="getOutputRows(id).length" class="output-list">
|
||||||
<div v-for="row in getOutputRows(id)" :key="row.label" class="output-row">
|
<div v-for="row in getOutputRows(id)" :key="row.label" class="output-row">
|
||||||
@@ -372,6 +448,7 @@ import { useWorkerRuntimeStore } from '@/store/workerRuntime'
|
|||||||
import {
|
import {
|
||||||
createOfficialAccountTask,
|
createOfficialAccountTask,
|
||||||
executeOfficialAccountWorkflowStep,
|
executeOfficialAccountWorkflowStep,
|
||||||
|
exportOfficialAccountDocument,
|
||||||
getOfficialAccountWorkflow,
|
getOfficialAccountWorkflow,
|
||||||
updateOfficialAccountTask,
|
updateOfficialAccountTask,
|
||||||
} from '@/api/officialAccount'
|
} from '@/api/officialAccount'
|
||||||
@@ -384,6 +461,10 @@ const stepKeyTopic = 'topic_recommendation'
|
|||||||
const stepKeyTitle = 'title_generation'
|
const stepKeyTitle = 'title_generation'
|
||||||
const stepKeyOutline = 'outline_generation'
|
const stepKeyOutline = 'outline_generation'
|
||||||
const stepKeyContent = 'content_creation'
|
const stepKeyContent = 'content_creation'
|
||||||
|
const stepKeyImagePrompts = 'image_prompt_generation'
|
||||||
|
const stepKeyImages = 'image_generation'
|
||||||
|
const stepKeyPreview = 'preview_export'
|
||||||
|
const stepKeyPageMD = 'page_markdown'
|
||||||
const stepKeyConfig = 'task_configuration'
|
const stepKeyConfig = 'task_configuration'
|
||||||
|
|
||||||
const creatingTask = ref(false)
|
const creatingTask = ref(false)
|
||||||
@@ -398,11 +479,12 @@ const fittedTaskId = ref('')
|
|||||||
const canvasNodes = ref([])
|
const canvasNodes = ref([])
|
||||||
const canvasEdges = ref([])
|
const canvasEdges = ref([])
|
||||||
const canvasZoomOnScroll = ref(true)
|
const canvasZoomOnScroll = ref(true)
|
||||||
const canvasTranslateExtent = [[-320, -260], [3280, 1560]]
|
const canvasTranslateExtent = [[-320, -260], [4680, 1680]]
|
||||||
const canvasNodeExtent = [[40, 80], [2480, 1120]]
|
const canvasNodeExtent = [[40, 80], [4320, 1320]]
|
||||||
const expandedHotspotKeys = ref([])
|
const expandedHotspotKeys = ref([])
|
||||||
const pollingToken = ref(0)
|
const pollingToken = ref(0)
|
||||||
const pollingStepKey = ref('')
|
const pollingStepKey = ref('')
|
||||||
|
const exportingFormat = ref('')
|
||||||
|
|
||||||
const taskForm = reactive({
|
const taskForm = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -420,6 +502,8 @@ const stepPayload = reactive({
|
|||||||
topic: '',
|
topic: '',
|
||||||
title: '',
|
title: '',
|
||||||
outline: '',
|
outline: '',
|
||||||
|
content: '',
|
||||||
|
contentWithPrompts: '',
|
||||||
requirements: '',
|
requirements: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -524,6 +608,10 @@ const workflowSteps = computed(() => {
|
|||||||
buildStepCard(3, stepKeyTitle, steps[stepKeyTitle]),
|
buildStepCard(3, stepKeyTitle, steps[stepKeyTitle]),
|
||||||
buildStepCard(4, stepKeyOutline, steps[stepKeyOutline]),
|
buildStepCard(4, stepKeyOutline, steps[stepKeyOutline]),
|
||||||
buildStepCard(5, stepKeyContent, steps[stepKeyContent]),
|
buildStepCard(5, stepKeyContent, steps[stepKeyContent]),
|
||||||
|
buildStepCard(6, stepKeyImagePrompts, steps[stepKeyImagePrompts]),
|
||||||
|
buildStepCard(7, stepKeyImages, steps[stepKeyImages]),
|
||||||
|
buildStepCard(8, stepKeyPreview, steps[stepKeyPreview]),
|
||||||
|
buildStepCard(9, stepKeyPageMD, steps[stepKeyPageMD]),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -558,6 +646,18 @@ function getOutputRows(stepKey) {
|
|||||||
if (stepKey === stepKeyContent) {
|
if (stepKey === stepKeyContent) {
|
||||||
return [{ label: '正文状态', value: output.content ? '已生成' : '未生成' }]
|
return [{ label: '正文状态', value: output.content ? '已生成' : '未生成' }]
|
||||||
}
|
}
|
||||||
|
if (stepKey === stepKeyImagePrompts) {
|
||||||
|
return [{ label: '提示词数量', value: String((output.image_prompts || []).length || 0) }]
|
||||||
|
}
|
||||||
|
if (stepKey === stepKeyImages) {
|
||||||
|
return [{ label: '图片数量', value: String((output.image_refs || []).length || 0) }]
|
||||||
|
}
|
||||||
|
if (stepKey === stepKeyPreview) {
|
||||||
|
return [{ label: '预览状态', value: output.preview_html ? '已生成' : '未生成' }]
|
||||||
|
}
|
||||||
|
if (stepKey === stepKeyPageMD) {
|
||||||
|
return [{ label: '分页数量', value: String((output.pages || []).length || 0) }]
|
||||||
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,7 +696,7 @@ const flowNodes = computed(() => {
|
|||||||
return nodes.map((item, index) => ({
|
return nodes.map((item, index) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: 'official-step',
|
type: 'official-step',
|
||||||
position: { x: 80 + index * 456, y: 150 },
|
position: { x: 80 + index * 410, y: 150 },
|
||||||
selectable: true,
|
selectable: true,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -612,6 +712,10 @@ const flowEdges = computed(() => {
|
|||||||
[stepKeyTopic, stepKeyTitle],
|
[stepKeyTopic, stepKeyTitle],
|
||||||
[stepKeyTitle, stepKeyOutline],
|
[stepKeyTitle, stepKeyOutline],
|
||||||
[stepKeyOutline, stepKeyContent],
|
[stepKeyOutline, stepKeyContent],
|
||||||
|
[stepKeyContent, stepKeyImagePrompts],
|
||||||
|
[stepKeyImagePrompts, stepKeyImages],
|
||||||
|
[stepKeyImages, stepKeyPreview],
|
||||||
|
[stepKeyPreview, stepKeyPageMD],
|
||||||
]
|
]
|
||||||
return chain.map(([source, target], index) => ({
|
return chain.map(([source, target], index) => ({
|
||||||
id: `edge-${source}-${target}`,
|
id: `edge-${source}-${target}`,
|
||||||
@@ -670,6 +774,10 @@ function stepPreview(key) {
|
|||||||
if (key === stepKeyTitle) return workflow.value?.shared?.selected_title || '围绕选题生成标题候选'
|
if (key === stepKeyTitle) return workflow.value?.shared?.selected_title || '围绕选题生成标题候选'
|
||||||
if (key === stepKeyOutline) return workflow.value?.shared?.outline ? firstLine(workflow.value.shared.outline) : '输出文章结构提纲'
|
if (key === stepKeyOutline) return workflow.value?.shared?.outline ? firstLine(workflow.value.shared.outline) : '输出文章结构提纲'
|
||||||
if (key === stepKeyContent) return workflow.value?.shared?.content ? `${workflow.value.shared.content.slice(0, 28)}...` : '输出正文草稿'
|
if (key === stepKeyContent) return workflow.value?.shared?.content ? `${workflow.value.shared.content.slice(0, 28)}...` : '输出正文草稿'
|
||||||
|
if (key === stepKeyImagePrompts) return (workflow.value?.shared?.image_prompts || []).length ? `已提炼 ${(workflow.value?.shared?.image_prompts || []).length} 条配图提示词` : '从正文提炼配图提示词'
|
||||||
|
if (key === stepKeyImages) return (workflow.value?.shared?.image_refs || []).length ? `已生成 ${(workflow.value?.shared?.image_refs || []).length} 张图片` : '根据提示词生成配图'
|
||||||
|
if (key === stepKeyPreview) return workflow.value?.shared?.preview_html ? '已生成图文预览稿' : '生成图文预览与导出稿'
|
||||||
|
if (key === stepKeyPageMD) return workflow.value?.shared?.page_markdown ? '已生成分页 Markdown' : '输出分页 MD'
|
||||||
return '等待执行'
|
return '等待执行'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,6 +788,10 @@ function canRunStep(key) {
|
|||||||
if (key === stepKeyTitle) return Boolean(stepPayload.topic || workflow.value?.shared?.selected_topic)
|
if (key === stepKeyTitle) return Boolean(stepPayload.topic || workflow.value?.shared?.selected_topic)
|
||||||
if (key === stepKeyOutline) return Boolean(stepPayload.title || workflow.value?.shared?.selected_title)
|
if (key === stepKeyOutline) return Boolean(stepPayload.title || workflow.value?.shared?.selected_title)
|
||||||
if (key === stepKeyContent) return Boolean(stepPayload.outline || workflow.value?.shared?.outline)
|
if (key === stepKeyContent) return Boolean(stepPayload.outline || workflow.value?.shared?.outline)
|
||||||
|
if (key === stepKeyImagePrompts) return Boolean(stepPayload.content || workflow.value?.shared?.content)
|
||||||
|
if (key === stepKeyImages) return Boolean(stepPayload.contentWithPrompts || workflow.value?.shared?.content_with_prompts || (workflow.value?.shared?.image_prompts || []).length)
|
||||||
|
if (key === stepKeyPreview) return Boolean(workflow.value?.shared?.content)
|
||||||
|
if (key === stepKeyPageMD) return Boolean(workflow.value?.shared?.content)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -689,6 +801,10 @@ function stepHintText(key) {
|
|||||||
if (key === stepKeyTitle) return '请先在上一节点确认选题'
|
if (key === stepKeyTitle) return '请先在上一节点确认选题'
|
||||||
if (key === stepKeyOutline) return '请先生成或选择标题'
|
if (key === stepKeyOutline) return '请先生成或选择标题'
|
||||||
if (key === stepKeyContent) return '请先生成或填写提纲'
|
if (key === stepKeyContent) return '请先生成或填写提纲'
|
||||||
|
if (key === stepKeyImagePrompts) return '请先生成正文'
|
||||||
|
if (key === stepKeyImages) return '请先生成配图提示词'
|
||||||
|
if (key === stepKeyPreview) return '请先生成正文与图片'
|
||||||
|
if (key === stepKeyPageMD) return '请先生成预览稿'
|
||||||
return '当前节点暂不可执行'
|
return '当前节点暂不可执行'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,6 +828,10 @@ function stepLabel(key) {
|
|||||||
if (key === stepKeyTitle) return '选题后生成标题'
|
if (key === stepKeyTitle) return '选题后生成标题'
|
||||||
if (key === stepKeyOutline) return '提纲生成'
|
if (key === stepKeyOutline) return '提纲生成'
|
||||||
if (key === stepKeyContent) return '正文创作'
|
if (key === stepKeyContent) return '正文创作'
|
||||||
|
if (key === stepKeyImagePrompts) return '配图提示词'
|
||||||
|
if (key === stepKeyImages) return '图片生成'
|
||||||
|
if (key === stepKeyPreview) return '预览与导出'
|
||||||
|
if (key === stepKeyPageMD) return '分页 MD'
|
||||||
return '节点'
|
return '节点'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,12 +882,33 @@ function toggleNodeExpand(stepKey) {
|
|||||||
|
|
||||||
function getNodeBubbleAlign(stepKey) {
|
function getNodeBubbleAlign(stepKey) {
|
||||||
if (stepKey === stepKeyContent) return 'end'
|
if (stepKey === stepKeyContent) return 'end'
|
||||||
|
if (stepKey === stepKeyImagePrompts) return 'center'
|
||||||
|
if (stepKey === stepKeyImages) return 'center'
|
||||||
|
if (stepKey === stepKeyPreview) return 'end'
|
||||||
|
if (stepKey === stepKeyPageMD) return 'end'
|
||||||
if (stepKey === stepKeyOutline) return 'center'
|
if (stepKey === stepKeyOutline) return 'center'
|
||||||
return 'start'
|
return 'start'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeBubbleWidth(stepKey) {
|
function getNodeBubbleWidth(stepKey) {
|
||||||
return stepKey === stepKeyConfig ? 560 : 500
|
if (stepKey === stepKeyConfig) return 560
|
||||||
|
if (stepKey === stepKeyPreview) return 700
|
||||||
|
if (stepKey === stepKeyPageMD) return 660
|
||||||
|
if (stepKey === stepKeyImages) return 620
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromptSlotLabel(item, index) {
|
||||||
|
if (item?.key === 'cover' || index === 0) return '封面配图'
|
||||||
|
return `内容配图 ${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromptPurpose(item, index) {
|
||||||
|
if (item?.key === 'cover' || index === 0) {
|
||||||
|
return '用于文章封面或头图,先传达主题和整体氛围'
|
||||||
|
}
|
||||||
|
const sectionName = item?.section || `内容段落 ${index}`
|
||||||
|
return `用于“${sectionName}”这一段的说明配图,帮助读者更直观理解内容`
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNodePanelWheel() {
|
function handleNodePanelWheel() {
|
||||||
@@ -805,6 +946,10 @@ function stepShortLabel(key) {
|
|||||||
if (key === stepKeyTitle) return '标题'
|
if (key === stepKeyTitle) return '标题'
|
||||||
if (key === stepKeyOutline) return '提纲'
|
if (key === stepKeyOutline) return '提纲'
|
||||||
if (key === stepKeyContent) return '正文'
|
if (key === stepKeyContent) return '正文'
|
||||||
|
if (key === stepKeyImagePrompts) return '提示词'
|
||||||
|
if (key === stepKeyImages) return '图片'
|
||||||
|
if (key === stepKeyPreview) return '预览'
|
||||||
|
if (key === stepKeyPageMD) return '分页'
|
||||||
return '节点'
|
return '节点'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +1022,7 @@ async function createTask() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadWorkflow(taskId = selectedTaskId.value, options = {}) {
|
async function loadWorkflow(taskId = selectedTaskId.value, options = {}) {
|
||||||
const { silent = false, preserveNodeState = false, syncRuntime = true } = options
|
const { silent = false, preserveNodeState = false, syncRuntime = false } = options
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
workflowDetail.value = null
|
workflowDetail.value = null
|
||||||
return
|
return
|
||||||
@@ -894,7 +1039,7 @@ async function loadWorkflow(taskId = selectedTaskId.value, options = {}) {
|
|||||||
expandedNodes.value = []
|
expandedNodes.value = []
|
||||||
}
|
}
|
||||||
if (syncRuntime) {
|
if (syncRuntime) {
|
||||||
await workerRuntime.loadTaskDetail(taskId)
|
void workerRuntime.loadTaskDetail(taskId)
|
||||||
}
|
}
|
||||||
if (fittedTaskId.value !== String(taskId)) {
|
if (fittedTaskId.value !== String(taskId)) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -920,6 +1065,8 @@ function syncSelectionsFromWorkflow() {
|
|||||||
stepPayload.topic = shared.selected_topic || ''
|
stepPayload.topic = shared.selected_topic || ''
|
||||||
stepPayload.title = shared.selected_title || ''
|
stepPayload.title = shared.selected_title || ''
|
||||||
stepPayload.outline = shared.outline || ''
|
stepPayload.outline = shared.outline || ''
|
||||||
|
stepPayload.content = shared.content || ''
|
||||||
|
stepPayload.contentWithPrompts = shared.content_with_prompts || ''
|
||||||
stepPayload.requirements = workflow.value?.form?.requirements || ''
|
stepPayload.requirements = workflow.value?.form?.requirements || ''
|
||||||
configForm.keyword = workflow.value?.form?.keyword || ''
|
configForm.keyword = workflow.value?.form?.keyword || ''
|
||||||
configForm.businessDomain = workflow.value?.form?.business_domain || 'auto'
|
configForm.businessDomain = workflow.value?.form?.business_domain || 'auto'
|
||||||
@@ -1016,14 +1163,16 @@ async function runStep(stepKey) {
|
|||||||
runningStepKey.value = stepKey
|
runningStepKey.value = stepKey
|
||||||
pollingStepKey.value = stepKey
|
pollingStepKey.value = stepKey
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
ElMessage.success(`${stepLabel(stepKey)}已开始执行`)
|
||||||
await executeOfficialAccountWorkflowStep(selectedTaskId.value, stepKey, {
|
await executeOfficialAccountWorkflowStep(selectedTaskId.value, stepKey, {
|
||||||
topic: stepPayload.topic,
|
topic: stepPayload.topic,
|
||||||
title: stepPayload.title,
|
title: stepPayload.title,
|
||||||
outline: stepPayload.outline,
|
outline: stepPayload.outline,
|
||||||
|
content: stepPayload.content,
|
||||||
|
content_with_prompts: stepPayload.contentWithPrompts,
|
||||||
requirements: stepPayload.requirements,
|
requirements: stepPayload.requirements,
|
||||||
})
|
})
|
||||||
await loadWorkflow(selectedTaskId.value, { silent: true, preserveNodeState: true, syncRuntime: false })
|
await loadWorkflow(selectedTaskId.value, { silent: true, preserveNodeState: true, syncRuntime: false })
|
||||||
ElMessage.success(`${stepLabel(stepKey)}已开始执行`)
|
|
||||||
await pollWorkflowUntilStepSettled(String(selectedTaskId.value), stepKey, { notifyOnFinish: true })
|
await pollWorkflowUntilStepSettled(String(selectedTaskId.value), stepKey, { notifyOnFinish: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
runningStepKey.value = ''
|
runningStepKey.value = ''
|
||||||
@@ -1035,10 +1184,31 @@ async function runStep(stepKey) {
|
|||||||
|
|
||||||
function findRunningStepKey(workflowState = workflow.value) {
|
function findRunningStepKey(workflowState = workflow.value) {
|
||||||
const steps = workflowState?.steps || {}
|
const steps = workflowState?.steps || {}
|
||||||
return [stepKeyTopic, stepKeyTitle, stepKeyOutline, stepKeyContent]
|
return [stepKeyTopic, stepKeyTitle, stepKeyOutline, stepKeyContent, stepKeyImagePrompts, stepKeyImages, stepKeyPreview, stepKeyPageMD]
|
||||||
.find(key => steps?.[key]?.status === 'running') || ''
|
.find(key => steps?.[key]?.status === 'running') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadExport(format, filename) {
|
||||||
|
if (!selectedTaskId.value) return
|
||||||
|
try {
|
||||||
|
exportingFormat.value = format
|
||||||
|
const blob = await exportOfficialAccountDocument(selectedTaskId.value, format)
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('导出文件已准备好')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error?.message || '导出失败')
|
||||||
|
} finally {
|
||||||
|
exportingFormat.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pollWorkflowUntilStepSettled(taskId, stepKey, options = {}) {
|
async function pollWorkflowUntilStepSettled(taskId, stepKey, options = {}) {
|
||||||
const { notifyOnFinish = false } = options
|
const { notifyOnFinish = false } = options
|
||||||
const token = pollingToken.value + 1
|
const token = pollingToken.value + 1
|
||||||
@@ -1532,12 +1702,187 @@ onMounted(async () => {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagemd-preview {
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-card,
|
||||||
|
.image-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(228, 231, 237, 0.9);
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-title,
|
||||||
|
.image-section {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-chip {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.06);
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-body,
|
||||||
|
.image-prompt {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #526071;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-raw-preview {
|
||||||
|
max-height: 260px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(191, 219, 254, 0.9);
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.action-row {
|
.action-row {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row-start {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-render {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(228, 231, 237, 0.9);
|
||||||
|
background: #fff;
|
||||||
|
padding: 18px;
|
||||||
|
max-height: min(60vh, 680px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-chip {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview) {
|
||||||
|
color: #1f2d3d;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview h1) {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview h2) {
|
||||||
|
margin: 22px 0 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview p),
|
||||||
|
:deep(.oa-preview blockquote),
|
||||||
|
:deep(.oa-preview li) {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview blockquote) {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid rgba(37, 99, 235, 0.35);
|
||||||
|
background: rgba(37, 99, 235, 0.05);
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview ul) {
|
||||||
|
margin: 10px 0 10px 20px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview-figure) {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview-figure img) {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.oa-preview-figure figcaption) {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
.oa-node {
|
.oa-node {
|
||||||
width: 360px;
|
width: 360px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
Reference in New Issue
Block a user