feat(wxpa): 配图提示词规则从 Go 硬编码迁移到 JSON 配置驱动

- 新增 spec/06-image_prompt_generation/rule.json 定义配图分布规则(500 字间隔、封面图、正文图尺寸)
- 新增 prompt_template、bracket_format 等 JSON 配置,支持中文提示词模板
- 修改 buildCoverImagePrompt/buildContentImagePrompt 使用 strings.ReplaceAll 替换 {index}/{section_summary} 占位符
- 修改 executeWeixinPublicAccountImagePromptStep 调用 buildWeixinPublicAccountImagePromptsV2
- 新增 buildWeixinPublicAccountContentWithPromptsFromJSON 包装 renderWeixinPublicAccountImagePromptFromJSON
- 前端产物 Tab 新增配图缩略图行展示
This commit is contained in:
eaiadmin
2026-09-25 00:19:15 +08:00
parent d1a737d448
commit e73169df50
4 changed files with 226 additions and 55 deletions
@@ -27,8 +27,8 @@ type weixinPublicAccountSection struct {
}
func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) weixinPublicAccountStepExecution {
prompts := buildWeixinPublicAccountImagePrompts(workflow)
contentWithPrompts := buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
prompts := buildWeixinPublicAccountImagePromptsV2(workflow)
contentWithPrompts := buildWeixinPublicAccountContentWithPromptsFromJSON(workflow.Shared.Content, prompts)
workflow.Shared.ContentWithPrompts = contentWithPrompts
workflow.Shared.ImagePrompts = prompts
@@ -55,9 +55,9 @@ func executeWeixinPublicAccountImagePromptStep(workflow *weixinPublicAccountWork
func executeWeixinPublicAccountImageGenerationStep(workflow *weixinPublicAccountWorkflowState, article *weixinpublicaccountmodel.WeixinPublicAccountArticle, user *model.User, now time.Time) (weixinPublicAccountStepExecution, *web.AppError) {
prompts := workflow.Shared.ImagePrompts
if len(prompts) == 0 {
prompts = buildWeixinPublicAccountImagePrompts(workflow)
prompts = buildWeixinPublicAccountImagePromptsV2(workflow)
workflow.Shared.ImagePrompts = prompts
workflow.Shared.ContentWithPrompts = buildWeixinPublicAccountContentWithPrompts(workflow.Shared.Content, prompts)
workflow.Shared.ContentWithPrompts = buildWeixinPublicAccountContentWithPromptsFromJSON(workflow.Shared.Content, prompts)
}
aiRoute, refs, appErr := generateWeixinPublicAccountImages(article.TaskID, workflow, prompts, user)
if appErr != nil {
@@ -181,6 +181,11 @@ func buildWeixinPublicAccountContentWithPrompts(content string, prompts []weixin
return strings.Join(blocks, "\n")
}
// buildWeixinPublicAccountContentWithPromptsFromJSON 基于 JSON 配置渲染带括号标记的正文。
func buildWeixinPublicAccountContentWithPromptsFromJSON(content string, prompts []weixinPublicAccountImagePrompt) string {
return renderWeixinPublicAccountImagePromptFromJSON(content, prompts)
}
func buildWeixinPublicAccountVisualPrompt(workflow *weixinPublicAccountWorkflowState, sectionTitle, context string) string {
keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题")
tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂")
@@ -217,18 +217,20 @@ func buildWeixinPublicAccountImagePromptsV2(workflow *weixinPublicAccountWorkflo
func buildCoverImagePrompt(workflow *weixinPublicAccountWorkflowState, rule *imagePromptRule) string {
title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章")
// 从 JSON 模板构建(当前回退到硬编码模板)
// 从 JSON 模板构建
template := rule.Template.Template
if template == "" {
// 回退到当前硬编码的英文模板
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",
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
title,
"公众号封面主视觉",
)
if template != "" {
// 使用字符串替换,而非 fmt.Sprintf
prompt := strings.ReplaceAll(template, "{index}", "1")
prompt = strings.ReplaceAll(prompt, "{section_summary}", fmt.Sprintf("%s(截断至120字)", title))
return prompt
}
return fmt.Sprintf(template, 1, title, fmt.Sprintf("%s(截断至120字)", title))
// 回退到硬编码模板
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",
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
title,
"公众号封面主视觉",
)
}
// buildContentImagePrompt 生成正文配图提示词。
@@ -237,16 +239,18 @@ func buildContentImagePrompt(workflow *weixinPublicAccountWorkflowState, section
// 从 JSON 模板构建
template := rule.Template.Template
if template == "" {
// 回退到当前硬编码的英文模板
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",
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
firstNonEmpty(section.Title, workflow.Form.Keyword),
firstNonEmpty(summary, workflow.Form.Tone),
)
if template != "" {
// 使用字符串替换,而非 fmt.Sprintf
prompt := strings.ReplaceAll(template, "{index}", fmt.Sprintf("%d", index))
prompt = strings.ReplaceAll(prompt, "{section_summary}", summary)
return prompt
}
return fmt.Sprintf(template, index, firstNonEmpty(section.Title, workflow.Form.Keyword), summary)
// 回退到硬编码模板
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",
weixinPublicAccountBusinessDomainLabel(workflow.Form.BusinessDomain),
firstNonEmpty(section.Title, workflow.Form.Keyword),
firstNonEmpty(summary, workflow.Form.Tone),
)
}
// countChineseWords 计算内容的中文字数。
@@ -792,7 +792,7 @@ func respondWeixinPublicAccountWorkflow(c *gin.Context, task model.TaskRecord) {
func findWeixinPublicAccountImagePrompt(workflow weixinPublicAccountWorkflowState, imageKey string) (weixinPublicAccountImagePrompt, int, bool) {
prompts := workflow.Shared.ImagePrompts
if len(prompts) == 0 {
prompts = buildWeixinPublicAccountImagePrompts(&workflow)
prompts = buildWeixinPublicAccountImagePromptsV2(&workflow)
workflow.Shared.ImagePrompts = prompts
}
for idx, item := range prompts {
@@ -148,20 +148,51 @@
</div>
<div v-else-if="group.kind === 'files'" class="oa-file-list">
<div v-for="file in group.files" :key="file.key" class="oa-file-row">
<span class="oa-file-kind">{{ file.kindLabel }}</span>
<div class="oa-file-main">
<span class="oa-file-name" :title="file.name">{{ file.name }}</span>
<span v-if="file.meta" class="oa-file-meta">{{ file.meta }}</span>
<div v-if="group.isImageGroup" class="oa-image-group">
<div class="oa-image-group-summary">{{ group.summary }}</div>
<div v-for="file in group.files" :key="file.key" class="oa-image-row">
<img
v-if="file.thumbnailUrl"
:src="file.thumbnailUrl"
:alt="file.name"
class="oa-image-thumb"
/>
<img
v-else
src="data:image/svg+xml;charset=utf-8,"
alt="加载中"
class="oa-image-thumb oa-image-thumb-loading"
/>
<div class="oa-image-row-info">
<span class="oa-image-row-section">{{ file.meta }}</span>
<span v-if="file.size" class="oa-image-row-size">{{ file.size }}</span>
</div>
<button
class="oa-btn oa-btn-tiny"
type="button"
:disabled="previewLoading"
@click="downloadFile(file)"
>
下载
</button>
</div>
</div>
<div v-else class="oa-file-list-plain">
<div v-for="file in group.files" :key="file.key" class="oa-file-row">
<span class="oa-file-kind">{{ file.kindLabel }}</span>
<div class="oa-file-main">
<span class="oa-file-name" :title="file.name">{{ file.name }}</span>
<span v-if="file.meta" class="oa-file-meta">{{ file.meta }}</span>
</div>
<button
class="oa-btn oa-btn-tiny"
type="button"
:disabled="previewLoading"
@click="downloadFile(file)"
>
下载
</button>
</div>
<button
class="oa-btn oa-btn-tiny"
type="button"
:disabled="previewLoading"
@click="downloadFile(file)"
>
下载
</button>
</div>
</div>
@@ -695,10 +726,49 @@ const artifactStepGroups = computed(() => orderedSteps.value.map((step, index) =
}
}))
const artifactItems = computed(() => artifactStepGroups.value.map((group) => group.artifact).filter(Boolean))
// 一份产物都没有时不再列 8 个「这一步还没有产物」的空壳,只留一句提示。
// 跑过几步的任务则八步全列,缺哪一步一眼能看出来。
const visibleArtifactGroups = computed(() => (artifactItems.value.length ? artifactStepGroups.value : []))
const visibleArtifactGroups = computed(() => (artifactItems.value.length ? artifactStepGroupsWithThumbnails.value : []))
const previewTitle = computed(() => (previewTarget.value?.name ? `预览 · ${previewTarget.value.name}` : '预览'))
const artifactImageUrls = computed(() => {
// Collect all image URLs from artifact files so we can load thumbnails
const urls = []
artifactStepGroups.value.forEach((group) => {
if (group.isImageGroup && group.files) {
group.files.forEach((file) => {
if (file.url) urls.push(String(file.url))
})
}
})
return urls
})
const artifactImageObjectUrls = computed(() => {
const result = {}
artifactImageUrls.value.forEach((url) => {
result[url] = imageObjectUrls.value[url] || ''
})
return result
})
// Augment artifact step groups with thumbnail URLs
const artifactStepGroupsWithThumbnails = computed(() => {
return artifactStepGroups.value.map((group) => {
if (group.isImageGroup && group.files) {
return {
...group,
files: group.files.map((file) => {
if (file.kind === 'image' && file.url) {
return {
...file,
thumbnailUrl: artifactImageObjectUrls.value[String(file.url)] || imageObjectUrls.value[String(file.url)] || '',
}
}
return file
}),
}
}
return group
})
})
const uploadItems = computed(() => {
const rawList = collectUploadItems(parseTaskContext(workflowTask.value))
return rawList.map((item, index) => normalizeUploadItem(item, index)).filter(Boolean)
@@ -1335,10 +1405,17 @@ function buildArtifactView(item, stepKey) {
}
if (base.kind === 'files') {
const files = buildArtifactFiles(payload, view.type)
if (!files.length) return degradeArtifactView(base, stepKey)
base.files = files
base.summary = artifactFilesSummary(files, payload, view.type)
const filesOrGroup = buildArtifactFiles(payload, view.type)
if (!filesOrGroup || (Array.isArray(filesOrGroup) && !filesOrGroup.length)) return degradeArtifactView(base, stepKey)
// images 返回的是 { isImageGroup: true, summary, files } 对象
if (filesOrGroup.isImageGroup) {
base.isImageGroup = true
base.summary = filesOrGroup.summary
base.files = filesOrGroup.files
} else {
base.files = filesOrGroup
base.summary = artifactFilesSummary(filesOrGroup, payload, view.type)
}
base.body = ''
return base
}
@@ -1444,17 +1521,22 @@ function artifactRowsSummary(payload, artifactType, rows) {
function buildArtifactFiles(payload, artifactType) {
if (artifactType === 'images') {
const refs = Array.isArray(payload?.image_refs) ? payload.image_refs : []
return refs.map((ref, idx) => {
const url = String(ref?.url || '').trim()
return {
key: `image-${ref?.key || idx}`,
kind: 'image',
kindLabel: '图片',
name: fileNameOfUrl(url) || `生成图 ${idx + 1}.png`,
meta: String(ref?.section || ref?.key || '').trim(),
url,
}
}).filter((file) => file.url)
return {
isImageGroup: true,
summary: `计划生成 ${refs.length} 张配图`,
files: refs.map((ref, idx) => {
const url = String(ref?.url || '').trim()
return {
key: `image-${ref?.key || idx}`,
kind: 'image',
kindLabel: '图片',
name: fileNameOfUrl(url) || `生成图 ${idx + 1}.png`,
meta: String(ref?.section || ref?.key || '').trim(),
size: ref?.image_size ? sizeLabel(ref.image_size) : '',
url,
}
}).filter((file) => file.url),
}
}
if (artifactType === 'preview') {
const html = String(payload?.preview_html || '').trim()
@@ -1551,12 +1633,31 @@ function formatFileSize(value) {
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
// 图片尺寸标签
function sizeLabel(imageSize) {
const map = {
'landscape_16_9': '16:9 横图',
'landscape_4_3': '4:3 横图',
'portrait_4_3': '4:3 竖图',
'square_hd': '1:1 方形',
}
return map[imageSize] || imageSize
}
watch(
() => [currentSpecialistKey.value, currentTaskId.value],
refresh,
{ immediate: true }
)
watch(
artifactImageUrls,
(urls) => {
urls.forEach((url) => ensureImageObjectUrl(url))
},
{ immediate: true, deep: true }
)
watch(
imageRefs,
(list) => {
@@ -2088,6 +2189,67 @@ onBeforeUnmount(() => {
margin-top: 4px;
}
/* 产物 Tab 中图片生成步骤的缩略图行 */
.oa-image-group-summary {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 8px;
font-weight: 500;
}
.oa-image-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f0f0f0;
}
.oa-image-row:last-child {
border-bottom: none;
}
.oa-image-thumb {
width: 48px;
height: 36px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
background: #f5f5f5;
}
.oa-image-thumb-loading {
opacity: 0;
transition: opacity 0.2s;
}
.oa-image-row-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.oa-image-row-section {
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-image-row-size {
font-size: 11px;
color: #999;
}
.oa-file-list-plain {
display: flex;
flex-direction: column;
gap: 6px;
}
.oa-image-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));