feat: Enhance article generation and outline handling

- Added support for external markdown synchronization in ArticleEditorCanvas.vue.
- Implemented a new function to sync markdown from props, ensuring the editor reflects external changes.
- Introduced a removeOutlineSection function in TemplateWizardView.vue to manage outline sections dynamically.
- Updated UI components to improve user experience, including replacing a-input with a-textarea for better text handling.
- Refactored CSS styles for a cleaner layout and improved responsiveness in TemplateWizardView.vue.
- Enhanced LLM generation requests to include response format options in ark.go and client.go.
- Updated template assist logic to enforce structured outline outputs in template_assist.go.
- Added tests for outline result decoding and prompt generation to ensure robustness.
- Created migration scripts to update prompt templates with new writing requirements for top X articles.
This commit is contained in:
2026-04-02 10:58:39 +08:00
parent b31d8d0096
commit 7fa1809682
12 changed files with 605 additions and 106 deletions
+159 -45
View File
@@ -471,6 +471,11 @@ func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repositor
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
Prompt: buildOutlinePrompt(job.TemplateKey, job.TemplateName, *job.OutlineRequest, job.OutlinePrompt),
ResponseFormat: &llm.ResponseFormat{
Type: llm.ResponseFormatTypeJSONObject,
Name: "article_outline",
Description: "文章大纲 JSON 对象,必须包含 outline 数组字段。",
},
}, nil)
if err != nil {
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
@@ -699,9 +704,9 @@ func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 关键词应为简洁的搜索短语。
- 竞品应去重且真实。不确定时 website 可留空,但不要编造虚假 URL
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接
- brand_summary 限 1-2 句。
- 最多返回 6 个竞品和 5 个关键词。
@@ -750,7 +755,7 @@ func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, ti
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回 5 个字符串的 JSON 数组,不要返回对象。
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 标题应实用、具体,并契合当前模板的内容方向。
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
- 避免泛泛的广告文案和空洞口号。
@@ -765,6 +770,25 @@ JSON 输出示例:
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
contextPayload := outlinePromptParams(templateKey, templateName, req)
outputExample := `{
"outline": [
{
"outline": "网站列表",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
]
},
{
"outline": "文章关键要点",
"children": [
{ "outline": "要点 1" },
{ "outline": "要点 2" }
]
}
]
}`
if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" {
basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload))
if basePrompt != "" {
@@ -775,31 +799,18 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
sections = append(sections, strings.Join([]string{
"输出要求:",
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
`- 返回大纲节点的 JSON 数组,每个节点格式{"outline":"...","children":[...]}`,
`- 返回 JSON 对象,格式固定{"outline":[...]}`,
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
}, "\n"))
sections = append(sections, "JSON 输出示例:\n"+outputExample)
return strings.Join(sections, "\n\n")
}
}
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
outputExample := `[
{
"outline": "网站列表",
"children": [
{ "outline": "竞品 A" },
{ "outline": "竞品 B" }
]
},
{
"outline": "文章关键要点",
"children": [
{ "outline": "要点 1" },
{ "outline": "要点 2" }
]
}
]`
return strings.TrimSpace(fmt.Sprintf(`
你是一位专业的 GEO 内容策略师。
@@ -809,17 +820,18 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
规则:
- 仅返回 JSON,不要用 Markdown 代码块包裹。
- 返回顶层大纲节点的 JSON 数组
- 每个顶层节点 "outline" 值必须保持已选段落标签原文。
- 返回 JSON 对象,格式固定为 {"outline":[...]}
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
- 大纲应具体、不含推广语气,适合后续完整成文。
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
Template context:
模板上下文:
%s
Output JSON example:
JSON 输出示例:
%s
`, string(contextJSON), outputExample))
}
@@ -996,29 +1008,45 @@ func decodeTitleResult(raw string) ([]string, error) {
}
func decodeOutlineResult(raw string) ([]OutlineNode, error) {
cleaned := stripJSONFence(raw)
var lastErr error
for _, candidate := range extractJSONCandidates(raw) {
var result []OutlineNode
if err := json.Unmarshal([]byte(candidate), &result); err == nil {
return result, nil
} else {
lastErr = err
}
var result []OutlineNode
if err := json.Unmarshal([]byte(cleaned), &result); err == nil {
return result, nil
var wrapped struct {
Result []OutlineNode `json:"result"`
Outline []OutlineNode `json:"outline"`
Items []OutlineNode `json:"items"`
Data []OutlineNode `json:"data"`
Sections []OutlineNode `json:"sections"`
}
if err := json.Unmarshal([]byte(candidate), &wrapped); err != nil {
lastErr = err
continue
}
switch {
case len(wrapped.Result) > 0:
return wrapped.Result, nil
case len(wrapped.Outline) > 0:
return wrapped.Outline, nil
case len(wrapped.Items) > 0:
return wrapped.Items, nil
case len(wrapped.Data) > 0:
return wrapped.Data, nil
case len(wrapped.Sections) > 0:
return wrapped.Sections, nil
default:
lastErr = fmt.Errorf("outline object does not contain a supported outline field")
}
}
var wrapped struct {
Result []OutlineNode `json:"result"`
Outline []OutlineNode `json:"outline"`
Items []OutlineNode `json:"items"`
}
if err := json.Unmarshal([]byte(cleaned), &wrapped); err != nil {
return nil, fmt.Errorf("decode outline result: %w", err)
}
switch {
case len(wrapped.Result) > 0:
return wrapped.Result, nil
case len(wrapped.Outline) > 0:
return wrapped.Outline, nil
default:
return wrapped.Items, nil
if lastErr == nil {
lastErr = fmt.Errorf("empty content")
}
return nil, fmt.Errorf("decode outline result: %w", lastErr)
}
func stripJSONFence(raw string) string {
@@ -1029,6 +1057,92 @@ func stripJSONFence(raw string) string {
return cleaned
}
func extractJSONCandidates(raw string) []string {
cleaned := stripJSONFence(raw)
if cleaned == "" {
return nil
}
candidates := make([]string, 0, 8)
seen := make(map[string]struct{}, 8)
addCandidate := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, exists := seen[value]; exists {
return
}
seen[value] = struct{}{}
candidates = append(candidates, value)
}
addCandidate(cleaned)
for i := 0; i < len(cleaned); i++ {
switch cleaned[i] {
case '{', '[':
if fragment, ok := extractBalancedJSONFragment(cleaned[i:]); ok {
addCandidate(fragment)
}
}
}
return candidates
}
func extractBalancedJSONFragment(input string) (string, bool) {
if input == "" {
return "", false
}
var open, close byte
switch input[0] {
case '{':
open = '{'
close = '}'
case '[':
open = '['
close = ']'
default:
return "", false
}
depth := 0
inString := false
escaped := false
for i := 0; i < len(input); i++ {
ch := input[i]
if inString {
if escaped {
escaped = false
continue
}
switch ch {
case '\\':
escaped = true
case '"':
inString = false
}
continue
}
switch ch {
case '"':
inString = true
case open:
depth += 1
case close:
depth -= 1
if depth == 0 {
return input[:i+1], true
}
}
}
return "", false
}
func normalizeAnalyzeResult(result AnalyzeTaskResult) AnalyzeTaskResult {
result.BrandSummary = strings.TrimSpace(result.BrandSummary)
result.Keywords = normalizeStringList(result.Keywords, 5)