From f864cc2c87c007f104653083076815f6907be32d Mon Sep 17 00:00:00 2001 From: Jackzhou Date: Wed, 26 Aug 2026 17:34:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E4=BC=97=E5=8F=B7=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- eai_ap_app/backend-go/internal/ai/credits.go | 2 + .../api/official_account_article_service.go | 54 +- .../internal/api/official_account_delivery.go | 593 ++++++++++++++++++ .../api/official_account_generated_image.go | 175 ++++++ .../internal/api/official_account_workflow.go | 167 ++++- eai_ap_app/backend-go/internal/api/router.go | 2 + .../backend-go/internal/api/worker_task.go | 4 + .../model/official_account_article.go | 9 +- .../frontend/src/api/officialAccount.js | 8 + .../workbench/OfficialAccountWorkflowPage.vue | 361 ++++++++++- 11 files changed, 1348 insertions(+), 30 deletions(-) create mode 100644 eai_ap_app/backend-go/internal/api/official_account_delivery.go create mode 100644 eai_ap_app/backend-go/internal/api/official_account_generated_image.go diff --git a/.gitignore b/.gitignore index 2df8024..449356f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ docs/09_Research/工业AI观察员/raw/ # ===== 操作系统 ===== .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db +.ollama_models/ \ No newline at end of file diff --git a/eai_ap_app/backend-go/internal/ai/credits.go b/eai_ap_app/backend-go/internal/ai/credits.go index e906710..9d4fbc8 100644 --- a/eai_ap_app/backend-go/internal/ai/credits.go +++ b/eai_ap_app/backend-go/internal/ai/credits.go @@ -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, } diff --git a/eai_ap_app/backend-go/internal/api/official_account_article_service.go b/eai_ap_app/backend-go/internal/api/official_account_article_service.go index 8d80db0..6047959 100644 --- a/eai_ap_app/backend-go/internal/api/official_account_article_service.go +++ b/eai_ap_app/backend-go/internal/api/official_account_article_service.go @@ -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" } diff --git a/eai_ap_app/backend-go/internal/api/official_account_delivery.go b/eai_ap_app/backend-go/internal/api/official_account_delivery.go new file mode 100644 index 0000000..1c5c75c --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/official_account_delivery.go @@ -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("\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(`
`) + 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(``) + listOpen = false + } + continue + } + if strings.HasPrefix(line, "![") && strings.Contains(line, "](") && strings.HasSuffix(line, ")") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + alt, src := parseOfficialAccountImageMarkdown(line) + builder.WriteString(fmt.Sprintf(`
%s
%s
`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(alt))) + continue + } + if strings.HasPrefix(line, "# ") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "# "))))) + continue + } + if strings.HasPrefix(line, "## ") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "## "))))) + continue + } + if strings.HasPrefix(line, "> ") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`
%s
`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "> "))))) + continue + } + if strings.HasPrefix(line, "- ") { + if !listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(line))) + } + if listOpen { + builder.WriteString(``) + } + builder.WriteString(`
`) + 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)) +} diff --git a/eai_ap_app/backend-go/internal/api/official_account_generated_image.go b/eai_ap_app/backend-go/internal/api/official_account_generated_image.go new file mode 100644 index 0000000..9939a1f --- /dev/null +++ b/eai_ap_app/backend-go/internal/api/official_account_generated_image.go @@ -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 +} diff --git a/eai_ap_app/backend-go/internal/api/official_account_workflow.go b/eai_ap_app/backend-go/internal/api/official_account_workflow.go index b346bb8..b9be3d7 100644 --- a/eai_ap_app/backend-go/internal/api/official_account_workflow.go +++ b/eai_ap_app/backend-go/internal/api/official_account_workflow.go @@ -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 "" } - diff --git a/eai_ap_app/backend-go/internal/api/router.go b/eai_ap_app/backend-go/internal/api/router.go index e52b48e..533f935 100644 --- a/eai_ap_app/backend-go/internal/api/router.go +++ b/eai_ap_app/backend-go/internal/api/router.go @@ -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) diff --git a/eai_ap_app/backend-go/internal/api/worker_task.go b/eai_ap_app/backend-go/internal/api/worker_task.go index 71b65b6..34ef807 100644 --- a/eai_ap_app/backend-go/internal/api/worker_task.go +++ b/eai_ap_app/backend-go/internal/api/worker_task.go @@ -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, diff --git a/eai_ap_app/backend-go/internal/model/official_account_article.go b/eai_ap_app/backend-go/internal/model/official_account_article.go index 5e95322..3426348 100644 --- a/eai_ap_app/backend-go/internal/model/official_account_article.go +++ b/eai_ap_app/backend-go/internal/model/official_account_article.go @@ -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"` diff --git a/eai_ap_app/frontend/src/api/officialAccount.js b/eai_ap_app/frontend/src/api/officialAccount.js index bfbad1b..72d0f25 100644 --- a/eai_ap_app/frontend/src/api/officialAccount.js +++ b/eai_ap_app/frontend/src/api/officialAccount.js @@ -17,3 +17,11 @@ export function executeOfficialAccountWorkflowStep(taskId, stepKey, data) { timeout: 0, }) } + +export function exportOfficialAccountDocument(taskId, format) { + return http.get(`/official-account/tasks/${taskId}/export`, { + params: { format }, + responseType: 'blob', + timeout: 0, + }) +} diff --git a/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue b/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue index 5593a2f..4d8adf6 100644 --- a/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue +++ b/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue @@ -330,6 +330,82 @@
{{ workflow.shared.content || '还没有生成正文草稿。' }}
+ + + + + + + +
节点输出
@@ -372,6 +448,7 @@ import { useWorkerRuntimeStore } from '@/store/workerRuntime' import { createOfficialAccountTask, executeOfficialAccountWorkflowStep, + exportOfficialAccountDocument, getOfficialAccountWorkflow, updateOfficialAccountTask, } from '@/api/officialAccount' @@ -384,6 +461,10 @@ const stepKeyTopic = 'topic_recommendation' const stepKeyTitle = 'title_generation' const stepKeyOutline = 'outline_generation' 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 creatingTask = ref(false) @@ -398,11 +479,12 @@ const fittedTaskId = ref('') const canvasNodes = ref([]) const canvasEdges = ref([]) const canvasZoomOnScroll = ref(true) -const canvasTranslateExtent = [[-320, -260], [3280, 1560]] -const canvasNodeExtent = [[40, 80], [2480, 1120]] +const canvasTranslateExtent = [[-320, -260], [4680, 1680]] +const canvasNodeExtent = [[40, 80], [4320, 1320]] const expandedHotspotKeys = ref([]) const pollingToken = ref(0) const pollingStepKey = ref('') +const exportingFormat = ref('') const taskForm = reactive({ keyword: '', @@ -420,6 +502,8 @@ const stepPayload = reactive({ topic: '', title: '', outline: '', + content: '', + contentWithPrompts: '', requirements: '', }) @@ -524,6 +608,10 @@ const workflowSteps = computed(() => { buildStepCard(3, stepKeyTitle, steps[stepKeyTitle]), buildStepCard(4, stepKeyOutline, steps[stepKeyOutline]), 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) { 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 [] } @@ -596,7 +696,7 @@ const flowNodes = computed(() => { return nodes.map((item, index) => ({ id: item.id, type: 'official-step', - position: { x: 80 + index * 456, y: 150 }, + position: { x: 80 + index * 410, y: 150 }, selectable: true, draggable: true, data: { @@ -612,6 +712,10 @@ const flowEdges = computed(() => { [stepKeyTopic, stepKeyTitle], [stepKeyTitle, stepKeyOutline], [stepKeyOutline, stepKeyContent], + [stepKeyContent, stepKeyImagePrompts], + [stepKeyImagePrompts, stepKeyImages], + [stepKeyImages, stepKeyPreview], + [stepKeyPreview, stepKeyPageMD], ] return chain.map(([source, target], index) => ({ id: `edge-${source}-${target}`, @@ -670,6 +774,10 @@ function stepPreview(key) { if (key === stepKeyTitle) return workflow.value?.shared?.selected_title || '围绕选题生成标题候选' 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 === 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 '等待执行' } @@ -680,6 +788,10 @@ function canRunStep(key) { 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 === 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 } @@ -689,6 +801,10 @@ function stepHintText(key) { if (key === stepKeyTitle) return '请先在上一节点确认选题' if (key === stepKeyOutline) return '请先生成或选择标题' if (key === stepKeyContent) return '请先生成或填写提纲' + if (key === stepKeyImagePrompts) return '请先生成正文' + if (key === stepKeyImages) return '请先生成配图提示词' + if (key === stepKeyPreview) return '请先生成正文与图片' + if (key === stepKeyPageMD) return '请先生成预览稿' return '当前节点暂不可执行' } @@ -712,6 +828,10 @@ function stepLabel(key) { if (key === stepKeyTitle) return '选题后生成标题' if (key === stepKeyOutline) return '提纲生成' if (key === stepKeyContent) return '正文创作' + if (key === stepKeyImagePrompts) return '配图提示词' + if (key === stepKeyImages) return '图片生成' + if (key === stepKeyPreview) return '预览与导出' + if (key === stepKeyPageMD) return '分页 MD' return '节点' } @@ -762,12 +882,33 @@ function toggleNodeExpand(stepKey) { function getNodeBubbleAlign(stepKey) { 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' return 'start' } 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() { @@ -805,6 +946,10 @@ function stepShortLabel(key) { if (key === stepKeyTitle) return '标题' if (key === stepKeyOutline) return '提纲' if (key === stepKeyContent) return '正文' + if (key === stepKeyImagePrompts) return '提示词' + if (key === stepKeyImages) return '图片' + if (key === stepKeyPreview) return '预览' + if (key === stepKeyPageMD) return '分页' return '节点' } @@ -877,7 +1022,7 @@ async function createTask() { } 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) { workflowDetail.value = null return @@ -894,7 +1039,7 @@ async function loadWorkflow(taskId = selectedTaskId.value, options = {}) { expandedNodes.value = [] } if (syncRuntime) { - await workerRuntime.loadTaskDetail(taskId) + void workerRuntime.loadTaskDetail(taskId) } if (fittedTaskId.value !== String(taskId)) { await nextTick() @@ -920,6 +1065,8 @@ function syncSelectionsFromWorkflow() { stepPayload.topic = shared.selected_topic || '' stepPayload.title = shared.selected_title || '' stepPayload.outline = shared.outline || '' + stepPayload.content = shared.content || '' + stepPayload.contentWithPrompts = shared.content_with_prompts || '' stepPayload.requirements = workflow.value?.form?.requirements || '' configForm.keyword = workflow.value?.form?.keyword || '' configForm.businessDomain = workflow.value?.form?.business_domain || 'auto' @@ -1016,14 +1163,16 @@ async function runStep(stepKey) { runningStepKey.value = stepKey pollingStepKey.value = stepKey await nextTick() + ElMessage.success(`${stepLabel(stepKey)}已开始执行`) await executeOfficialAccountWorkflowStep(selectedTaskId.value, stepKey, { topic: stepPayload.topic, title: stepPayload.title, outline: stepPayload.outline, + content: stepPayload.content, + content_with_prompts: stepPayload.contentWithPrompts, requirements: stepPayload.requirements, }) await loadWorkflow(selectedTaskId.value, { silent: true, preserveNodeState: true, syncRuntime: false }) - ElMessage.success(`${stepLabel(stepKey)}已开始执行`) await pollWorkflowUntilStepSettled(String(selectedTaskId.value), stepKey, { notifyOnFinish: true }) } catch (error) { runningStepKey.value = '' @@ -1035,10 +1184,31 @@ async function runStep(stepKey) { function findRunningStepKey(workflowState = workflow.value) { const steps = workflowState?.steps || {} - return [stepKeyTopic, stepKeyTitle, stepKeyOutline, stepKeyContent] + return [stepKeyTopic, stepKeyTitle, stepKeyOutline, stepKeyContent, stepKeyImagePrompts, stepKeyImages, stepKeyPreview, stepKeyPageMD] .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 = {}) { const { notifyOnFinish = false } = options const token = pollingToken.value + 1 @@ -1532,12 +1702,187 @@ onMounted(async () => { 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 { margin-top: 8px; display: flex; 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 { width: 360px; padding: 20px;