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:
+9
-4
@@ -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, "专业但好懂")
|
||||
|
||||
+23
-19
@@ -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 计算内容的中文字数。
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+193
-31
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user