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 index 67d075c..8d11e89 100644 --- a/eai_ap_app/backend-go/internal/api/official_account_delivery.go +++ b/eai_ap_app/backend-go/internal/api/official_account_delivery.go @@ -213,44 +213,68 @@ func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflo 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())) + ref, appErr := generateOfficialAccountImageRefWithRoute(client, route, taskID, item, idx, user) + if appErr != nil { + return nil, nil, appErr } - 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, - }) + refs = append(refs, ref) } return route, refs, nil } +func generateOfficialAccountImageRef(taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (*config.RouteConfig, officialAccountImageRef, *web.AppError) { + route, err := config.GetRoute("image_gen") + if err != nil { + return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置") + } + if route.Category != "image" || strings.TrimSpace(route.FullURL) == "" || strings.TrimSpace(route.Model) == "" { + return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整") + } + client := &http.Client{Timeout: 240 * time.Second} + ref, appErr := generateOfficialAccountImageRefWithRoute(client, route, taskID, prompt, index, user) + if appErr != nil { + return nil, officialAccountImageRef{}, appErr + } + return route, ref, nil +} + +func generateOfficialAccountImageRefWithRoute(client *http.Client, route *config.RouteConfig, taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (officialAccountImageRef, *web.AppError) { + imageSize := officialAccountImageSlotSize(index) + requestSize := officialAccountImageRequestSize(imageSize) + started := time.Now() + imageURL, reqErr := requestOfficialAccountImage(client, route, prompt.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 officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error())) + } + if strings.HasPrefix(strings.TrimSpace(imageURL), "data:image/") { + imageURL, reqErr = persistOfficialAccountDataURL(taskID, prompt.Key, imageURL) + if reqErr != nil { + return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error())) + } + } + return officialAccountImageRef{ + Key: prompt.Key, + Section: prompt.Section, + Prompt: prompt.Prompt, + URL: imageURL, + ImageSize: imageSize, + RouteID: route.RouteID, + RouteModel: route.Model, + RouteProvider: route.Provider, + }, nil +} + func requestOfficialAccountImage(client *http.Client, route *config.RouteConfig, prompt, size string) (string, error) { payload := map[string]any{ "model": route.Model, 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 3ec2dbb..2453199 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 @@ -516,6 +516,133 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { }) } +func RegenerateOfficialAccountImage(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 + } + + imageKey := strings.TrimSpace(c.Param("imageKey")) + if imageKey == "" { + web.Fail(c, web.NewBadRequest("缺少图片标识")) + return + } + + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, workflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&workflow, article) + + prompt, index, ok := findOfficialAccountImagePrompt(workflow, imageKey) + if !ok { + web.Fail(c, web.NewBadRequest("没有找到要重生成的图片")) + return + } + + now := time.Now() + route, ref, appErr := generateOfficialAccountImageRef(article.TaskID, prompt, index, user) + if appErr != nil { + web.Fail(c, appErr) + return + } + + workflow.Shared.ImageRefs = upsertOfficialAccountImageRef(workflow.Shared.ImageRefs, workflow.Shared.ImagePrompts, ref) + refreshOfficialAccountDerivedAssets(&workflow) + + output := map[string]any{ + "image_refs": workflow.Shared.ImageRefs, + "count": len(workflow.Shared.ImageRefs), + "regenerated_key": ref.Key, + "regenerated_ref": ref, + "route_id": route.RouteID, + "route_model": route.Model, + "route_provider": route.Provider, + } + logs := []string{ + fmt.Sprintf("%s 已重新生成图片「%s」。", now.Format("15:04:05"), firstNonEmpty(ref.Section, ref.Key)), + fmt.Sprintf("%s 预览稿与分页稿中的图片地址已同步刷新。", now.Format("15:04:05")), + } + setOfficialAccountStepCompleted(&workflow, officialAccountStepKeyImages, output, logs, now) + + task.Status = workerTaskStatusDraft + task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) + task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) + task.ContextJSON = marshalOfficialAccountWorkflow(workflow) + task.CurrentResult = fmt.Sprintf("已重新生成图片「%s」。", firstNonEmpty(ref.Section, ref.Key)) + task.LastTriggeredAt = &now + syncOfficialAccountArticleFromWorkflow(article, workflow) + + inputJSON, _ := json.Marshal(gin.H{ + "image_key": imageKey, + "section": prompt.Section, + "prompt": prompt.Prompt, + }) + outputPayload := cloneOutputMap(output) + outputPayload["summary"] = task.CurrentResult + outputPayload["keyword"] = workflow.Form.Keyword + outputJSON, _ := json.Marshal(outputPayload) + logsJSON, _ := json.Marshal(logs) + run := model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: "image_regeneration", + ActionTitle: "单张图片重生成", + ActionType: "workflow_step", + Status: "done", + InputJSON: string(inputJSON), + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := store.DB.Create(&run).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存单张图片重生成记录失败")) + return + } + task.CurrentRunID = &run.ID + + artifact := &model.WorkerArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: fmt.Sprintf("单张重生成图片 - %s", firstNonEmpty(ref.Section, ref.Key)), + ArtifactType: "images", + Status: workerArtifactStatusDraft, + ContentText: buildOfficialAccountImageArtifact([]officialAccountImageRef{ref}), + ContentJSON: string(outputJSON), + SourceRefsJSON: "[]", + CreatedByRunID: &run.ID, + } + if err := store.DB.Create(artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存单张图片结果失败")) + return + } + + if err := store.DB.Save(article).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) + return + } + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新公众号任务失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "workflow": workflow, + "run": run, + "artifact": artifact, + "image_ref": ref, + }) +} + func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { workflow := parseOfficialAccountWorkflow(task.ContextJSON) article, _ := ensureOfficialAccountArticle(task, workflow) @@ -549,6 +676,87 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { }) } +func findOfficialAccountImagePrompt(workflow officialAccountWorkflowState, imageKey string) (officialAccountImagePrompt, int, bool) { + prompts := workflow.Shared.ImagePrompts + if len(prompts) == 0 { + prompts = buildOfficialAccountImagePrompts(&workflow) + workflow.Shared.ImagePrompts = prompts + } + for idx, item := range prompts { + if strings.EqualFold(strings.TrimSpace(item.Key), imageKey) { + return item, idx, true + } + } + for idx, item := range workflow.Shared.ImageRefs { + if strings.EqualFold(strings.TrimSpace(item.Key), imageKey) { + return officialAccountImagePrompt{ + Key: item.Key, + Section: item.Section, + Prompt: item.Prompt, + }, idx, true + } + } + return officialAccountImagePrompt{}, -1, false +} + +func upsertOfficialAccountImageRef(existing []officialAccountImageRef, prompts []officialAccountImagePrompt, next officialAccountImageRef) []officialAccountImageRef { + byKey := make(map[string]officialAccountImageRef, len(existing)+1) + for _, item := range existing { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + byKey[key] = item + } + byKey[strings.TrimSpace(next.Key)] = next + + result := make([]officialAccountImageRef, 0, len(byKey)) + used := map[string]struct{}{} + for _, prompt := range prompts { + key := strings.TrimSpace(prompt.Key) + if key == "" { + continue + } + item, ok := byKey[key] + if !ok { + continue + } + result = append(result, item) + used[key] = struct{}{} + } + for _, item := range existing { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := used[key]; ok { + continue + } + if updated, exists := byKey[key]; exists { + result = append(result, updated) + used[key] = struct{}{} + } + } + if _, ok := used[strings.TrimSpace(next.Key)]; !ok { + result = append(result, next) + } + return result +} + +func refreshOfficialAccountDerivedAssets(workflow *officialAccountWorkflowState) { + if workflow == nil { + return + } + if strings.TrimSpace(workflow.Shared.PreviewMarkdown) != "" || strings.TrimSpace(workflow.Shared.PreviewHTML) != "" || strings.TrimSpace(workflow.Shared.PageMarkdown) != "" || len(workflow.Shared.Pages) > 0 { + workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow) + workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown) + } + if strings.TrimSpace(workflow.Shared.PageMarkdown) != "" || len(workflow.Shared.Pages) > 0 { + workflow.Shared.Pages = buildOfficialAccountPages(workflow) + workflow.Shared.PageMarkdown = buildOfficialAccountPageMarkdown(workflow.Shared.Pages) + } +} + func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccountWorkflowState { workflow := officialAccountWorkflowState{ WorkflowType: "wechat_official_account", diff --git a/eai_ap_app/backend-go/internal/api/router.go b/eai_ap_app/backend-go/internal/api/router.go index 533f935..64c7ae4 100644 --- a/eai_ap_app/backend-go/internal/api/router.go +++ b/eai_ap_app/backend-go/internal/api/router.go @@ -51,6 +51,7 @@ 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.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), RegenerateOfficialAccountImage) 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) diff --git a/eai_ap_app/frontend/src/api/officialAccount.js b/eai_ap_app/frontend/src/api/officialAccount.js index 72d0f25..1517a38 100644 --- a/eai_ap_app/frontend/src/api/officialAccount.js +++ b/eai_ap_app/frontend/src/api/officialAccount.js @@ -18,6 +18,12 @@ export function executeOfficialAccountWorkflowStep(taskId, stepKey, data) { }) } +export function regenerateOfficialAccountImage(taskId, imageKey) { + return http.post(`/official-account/tasks/${taskId}/images/${imageKey}/regenerate`, {}, { + timeout: 0, + }) +} + export function exportOfficialAccountDocument(taskId, format) { return http.get(`/official-account/tasks/${taskId}/export`, { params: { format }, diff --git a/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue b/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue index dbdbb08..690a7a4 100644 --- a/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue +++ b/eai_ap_app/frontend/src/views/workbench/OfficialAccountWorkflowPage.vue @@ -410,6 +410,16 @@ />
{{ item.section || item.key }}
{{ item.prompt }}
+
+ + 重生成本张 + +
当前还没有图片结果。
@@ -501,6 +511,7 @@ import { executeOfficialAccountWorkflowStep, exportOfficialAccountDocument, getOfficialAccountWorkflow, + regenerateOfficialAccountImage, updateOfficialAccountTask, } from '@/api/officialAccount' @@ -546,6 +557,7 @@ const expandedHotspotKeys = ref([]) const pollingToken = ref(0) const pollingStepKey = ref('') const exportingFormat = ref('') +const regeneratingImageKey = ref('') const imagePreviewSources = ref({}) const imageExportSources = ref({}) const imagePreviewStates = ref({}) @@ -1585,6 +1597,26 @@ async function runStep(stepKey) { } } +async function regenerateSingleImage(item) { + if (!selectedTaskId.value || !item?.key) { + ElMessage.warning('当前没有可重生成的图片') + return + } + try { + regeneratingImageKey.value = item.key + ElMessage.success(`开始重生成「${item.section || item.key}」`) + await regenerateOfficialAccountImage(selectedTaskId.value, item.key) + await loadWorkflow(selectedTaskId.value, { silent: true, preserveNodeState: true, syncRuntime: false }) + await workerRuntime.loadTasks(workspaceKey.value) + await workerRuntime.loadTaskDetail(selectedTaskId.value) + ElMessage.success(`已重生成「${item.section || item.key}」`) + } catch (error) { + ElMessage.error(error?.message || '单张图片重生成失败') + } finally { + regeneratingImageKey.value = '' + } +} + function findRunningStepKey(workflowState = workflow.value) { const steps = workflowState?.steps || {} return workflowStepKeys.find(key => steps?.[key]?.status === 'running') || '' @@ -2248,6 +2280,12 @@ onBeforeUnmount(() => { color: #b91c1c; } +.image-actions { + margin-top: 10px; + display: flex; + justify-content: flex-end; +} + .image-thumb { margin-top: 8px; width: 100%;