Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ deck:
# deck.theme 是 fixed 模式的主题,也是 weekly 当天未配置时的兜底主题。
# 可选值:default / warm-paper / editorial-cool / tech-noir / plum-ink / sage-mist / fresh-green
theme: default
# 单次 deck 允许的最大页数,范围 3-18;默认 12。
max_pages: 12
weekly_themes:
mon: default
tue: warm-paper
Expand Down
20 changes: 15 additions & 5 deletions internal/ai/deck_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import (
"strings"
)

const deckPromptConstraints = `你是一个严格的 JSON 生成器。
请把输入的 Markdown 内容转换成 3-12 页动态小红书预览 deck JSON。
const defaultMaxPages = 12

const deckPromptConstraintsTemplate = `你是一个严格的 JSON 生成器。
请把输入的 Markdown 内容转换成 3-%d 页动态小红书预览 deck JSON。
要求:
1. 只能输出 JSON,不要输出解释、代码块或额外文本
2. pages 数量必须在 3 到 12 页之间(3-12 页)
2. pages 数量必须在 3 到 %d 页之间(3-%d 页)
3. 第一页必须是 cover
4. 最后一页必须是 ending
5. 每页结构必须包含:name、variant、meta、content
Expand Down Expand Up @@ -58,12 +60,20 @@ type Builder struct {
Command string
Args []string
PromptExtra string
MaxPages int
Runner CommandRunner
}

func buildDeckPrompt(markdown, promptExtra string) string {
return buildDeckPromptWithMaxPages(markdown, promptExtra, defaultMaxPages)
}

func buildDeckPromptWithMaxPages(markdown, promptExtra string, maxPages int) string {
if maxPages == 0 {
maxPages = defaultMaxPages
}
var sb strings.Builder
sb.WriteString(deckPromptConstraints)
sb.WriteString(fmt.Sprintf(deckPromptConstraintsTemplate, maxPages, maxPages, maxPages))
if extra := strings.TrimSpace(promptExtra); extra != "" {
sb.WriteString("\n\n")
sb.WriteString(promptExtraIntro)
Expand Down Expand Up @@ -107,7 +117,7 @@ func (b Builder) BuildDeckJSON(markdown string) (string, error) {
if shouldUseBareOutput(b.Command, b.Args) && !containsArg(args, "--bare") {
args = append(args, "--bare")
}
args = append(args, "-p", buildDeckPrompt(markdown, b.PromptExtra))
args = append(args, "-p", buildDeckPromptWithMaxPages(markdown, b.PromptExtra, b.MaxPages))
stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...)
if err != nil {
return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
Expand Down
15 changes: 15 additions & 0 deletions internal/ai/deck_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ func TestBuildDeckPromptIgnoresWhitespaceOnlyPromptExtra(t *testing.T) {
}
}

func TestBuildDeckJSONUsesConfiguredMaxPages(t *testing.T) {
runner := &fakeRunner{stdout: `{"pages":[]}`}
b := Builder{Runner: runner, MaxPages: 18}
b.SetCommand("ccs", []string{"codex"})

_, err := b.BuildDeckJSON("# title")
if err != nil {
t.Fatalf("BuildDeckJSON() error = %v", err)
}
prompt := runner.args[len(runner.args)-1]
if !strings.Contains(prompt, "3-18 页") || !strings.Contains(prompt, "3 到 18 页之间") {
t.Fatalf("prompt = %q, want configured max page range", prompt)
}
}

func TestBuildDeckJSONUsesConfiguredCommand(t *testing.T) {
runner := &fakeRunner{stdout: `{"pages":[]}`}
b := Builder{Runner: runner}
Expand Down
8 changes: 4 additions & 4 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func (s Service) GeneratePreview(opts Options) (Result, error) {
return Result{}, fmt.Errorf("%w: %w", ErrBuildDeckJSON, err)
}

d, err := deck.FromJSON(rawJSON, opts.OutDir)
d, err := deck.FromJSONWithMaxPages(rawJSON, opts.OutDir, cfg.Deck.MaxPages)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrParseDeck, err)
}
Expand Down Expand Up @@ -177,7 +177,7 @@ func (s Service) GenerateFromDeck(opts Options) (Result, error) {
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrReadDeck, err)
}
d, err := deck.FromJSON(string(deckBytes), opts.OutDir)
d, err := deck.FromJSONWithMaxPages(string(deckBytes), opts.OutDir, cfg.Deck.MaxPages)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrParseDeck, err)
}
Expand Down Expand Up @@ -362,7 +362,7 @@ func (s Service) renderDeck(opts Options, cfg *config.Config, d deck.Deck, sourc
}
configTheme := resolveConfigDeckTheme(cfg.Deck, s.effectiveNow()())
d.ThemeName = resolveThemeWithPrecedence(opts.Theme, metaTheme, configTheme, d.ThemeName)
if err := d.Validate(); err != nil {
if err := d.ValidateWithMaxPages(cfg.Deck.MaxPages); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrParseDeck, err)
}
d.ViewportWidth = opts.ViewportWidth
Expand Down Expand Up @@ -446,7 +446,7 @@ func (s Service) effectiveBuildDeckJSON() func(*config.Config, string) (string,
return s.BuildDeckJSON
}
return func(cfg *config.Config, markdown string) (string, error) {
b := ai.Builder{PromptExtra: s.PromptExtra, Runner: s.AICommandRunner}
b := ai.Builder{PromptExtra: s.PromptExtra, MaxPages: cfg.Deck.MaxPages, Runner: s.AICommandRunner}
b.SetCommand(cfg.AI.Command, cfg.AI.Args)
return b.BuildDeckJSON(markdown)
}
Expand Down
84 changes: 84 additions & 0 deletions internal/app/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ func (r *fakeRenderer) Render(d deck.Deck) (render.RenderResult, error) {
return r.result, r.err
}

func deckJSONWithPageCount(count int) string {
pages := make([]string, 0, count)
pages = append(pages, fmt.Sprintf(`{"name":"p01-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/%d","theme":"default","cta":"cta"},"content":{"title":"封面"}}`, count))
for i := 2; i < count; i++ {
pages = append(pages, fmt.Sprintf(`{"name":"p%02d-bullets","variant":"bullets","meta":{"badge":"第 %d 页","counter":"%d/%d","theme":"default","cta":"cta"},"content":{"title":"列表页 %d","items":["要点"]}}`, i, i, i, count, i))
}
pages = append(pages, fmt.Sprintf(`{"name":"p%02d-ending","variant":"ending","meta":{"badge":"第 %d 页","counter":"%d/%d","theme":"default","cta":"cta"},"content":{"title":"结尾","body":"正文"}}`, count, count, count, count))
return `{"pages":[` + strings.Join(pages, ",") + `]}`
}

func TestServiceGeneratePreviewPassesPromptExtraToAIBuilder(t *testing.T) {
cfg := &config.Config{Output: config.OutputCfg{Dir: t.TempDir()}, AI: config.AICfg{Command: "ccs", Args: []string{"codex"}}}
runner := &fakeAICommandRunner{stdout: `{"pages":[{"name":"p1-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"orange","cta":"cta1"},"content":{"title":"封面"}},{"name":"p2-bullets","variant":"bullets","meta":{"badge":"第 2 页","counter":"2/3","theme":"orange","cta":"cta2"},"content":{"title":"中间","items":["要点"]}},{"name":"p3-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"green","cta":"cta3"},"content":{"title":"结尾","body":"正文"}}]}`}
Expand Down Expand Up @@ -73,6 +83,50 @@ func TestServiceGeneratePreviewPassesPromptExtraToAIBuilder(t *testing.T) {
}
}

func TestServiceGeneratePreviewPassesMaxPagesToAIBuilder(t *testing.T) {
cfg := &config.Config{Output: config.OutputCfg{Dir: t.TempDir()}, AI: config.AICfg{Command: "ccs", Args: []string{"codex"}}, Deck: config.DeckCfg{MaxPages: 18}}
runner := &fakeAICommandRunner{stdout: `{"pages":[{"name":"p1-cover","variant":"cover","meta":{"badge":"第 1 页","counter":"1/3","theme":"orange","cta":"cta1"},"content":{"title":"封面"}},{"name":"p2-bullets","variant":"bullets","meta":{"badge":"第 2 页","counter":"2/3","theme":"orange","cta":"cta2"},"content":{"title":"中间","items":["要点"]}},{"name":"p3-ending","variant":"ending","meta":{"badge":"第 3 页","counter":"3/3","theme":"green","cta":"cta3"},"content":{"title":"结尾","body":"正文"}}]}`}
_svcRenderer := &fakeRenderer{}
svc := Service{
LoadConfig: func(string) (*config.Config, error) { return cfg, nil },
ReadFile: func(string) ([]byte, error) { return []byte("# 标题"), nil },
AICommandRunner: runner,
NewRenderer: func(Options) DeckRenderer { return _svcRenderer },
}

_, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2})
if err != nil {
t.Fatalf("GeneratePreview() error = %v", err)
}
prompt := runner.args[len(runner.args)-1]
if !strings.Contains(prompt, "3-18 页") {
t.Fatalf("prompt = %q, want configured max pages", prompt)
}
}

func TestServiceGeneratePreviewAcceptsConfiguredMaxPages(t *testing.T) {
cfg := &config.Config{
Output: config.OutputCfg{Dir: t.TempDir()},
AI: config.AICfg{Command: "ccs", Args: []string{"codex"}},
Deck: config.DeckCfg{MaxPages: 18},
}
r := &fakeRenderer{}
svc := Service{
LoadConfig: func(string) (*config.Config, error) { return cfg, nil },
ReadFile: func(string) ([]byte, error) { return []byte("# 标题"), nil },
BuildDeckJSON: func(*config.Config, string) (string, error) { return deckJSONWithPageCount(18), nil },
NewRenderer: func(Options) DeckRenderer { return r },
}

_, err := svc.GeneratePreview(Options{InputPath: "article.md", ConfigPath: "config.yaml", Jobs: 2})
if err != nil {
t.Fatalf("GeneratePreview() error = %v", err)
}
if len(r.rendered.Pages) != 18 {
t.Fatalf("renderer received %d pages, want 18", len(r.rendered.Pages))
}
}

func TestServiceGeneratePreviewSuccess(t *testing.T) {
cfg := &config.Config{
Output: config.OutputCfg{Dir: t.TempDir()},
Expand Down Expand Up @@ -365,6 +419,36 @@ func TestServiceGenerateFromDeckReadsDeckAndSkipsMarkdownBuilder(t *testing.T) {
}
}

func TestServiceGenerateFromDeckAcceptsConfiguredMaxPages(t *testing.T) {
deckDir := t.TempDir()
deckPath := filepath.Join(deckDir, "deck.json")
deckJSON := deckJSONWithPageCount(18)
cfg := &config.Config{Output: config.OutputCfg{Dir: t.TempDir()}, Deck: config.DeckCfg{MaxPages: 18}}
r := &fakeRenderer{}
svc := Service{
LoadConfig: func(string) (*config.Config, error) { return cfg, nil },
ReadFile: func(path string) ([]byte, error) {
if path == deckPath {
return []byte(deckJSON), nil
}
return nil, os.ErrNotExist
},
BuildDeckJSON: func(*config.Config, string) (string, error) {
t.Fatalf("BuildDeckJSON should not be called in from-deck mode")
return "", nil
},
NewRenderer: func(Options) DeckRenderer { return r },
}

_, err := svc.GenerateFromDeck(Options{FromDeckPath: deckPath, ConfigPath: "config.yaml", Jobs: 2})
if err != nil {
t.Fatalf("GenerateFromDeck() error = %v", err)
}
if len(r.rendered.Pages) != 18 {
t.Fatalf("renderer received %d pages, want 18", len(r.rendered.Pages))
}
}

func TestServiceGenerateFromDeckUsesRenderMetaBeforeConfigDefaults(t *testing.T) {
deckDir := t.TempDir()
deckPath := filepath.Join(deckDir, "deck.json")
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type DeckCfg struct {
ThemeMode string `yaml:"theme_mode"`
WeeklyThemes map[string]string `yaml:"weekly_themes"`
Author string `yaml:"author"`
MaxPages int `yaml:"max_pages"`
Watermark WatermarkCfg `yaml:"watermark"`
}

Expand Down Expand Up @@ -271,6 +272,12 @@ func Load(configPath string) (*Config, error) {
if cfg.Deck.WeeklyThemes == nil {
cfg.Deck.WeeklyThemes = cloneStringMap(defaultDeckWeeklyThemes)
}
if cfg.Deck.MaxPages == 0 {
cfg.Deck.MaxPages = 12
}
if cfg.Deck.MaxPages < 3 || cfg.Deck.MaxPages > 18 {
return nil, fmt.Errorf("validate deck.max_pages: must be between 3 and 18")
}
for day, theme := range cfg.Deck.WeeklyThemes {
if !validDeckWeekday(day) {
return nil, fmt.Errorf("validate deck.weekly_themes.%s: unsupported weekday", day)
Expand Down
40 changes: 40 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"fmt"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -48,6 +49,45 @@ func TestLoadAppliesDefaultDeckConfig(t *testing.T) {
if cfg.Deck.Author != "" {
t.Fatalf("Deck.Author = %q, want empty", cfg.Deck.Author)
}
if cfg.Deck.MaxPages != 12 {
t.Fatalf("Deck.MaxPages = %d, want 12", cfg.Deck.MaxPages)
}
}

func TestLoadKeepsExplicitDeckMaxPages(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := "deck:\n max_pages: 18\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Deck.MaxPages != 18 {
t.Fatalf("Deck.MaxPages = %d, want 18", cfg.Deck.MaxPages)
}
}

func TestLoadRejectsInvalidDeckMaxPages(t *testing.T) {
for _, maxPages := range []int{2, 19} {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := fmt.Sprintf("deck:\n max_pages: %d\n", maxPages)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

_, err := Load(path)
if err == nil {
t.Fatalf("Load() error = nil for max_pages %d, want non-nil", maxPages)
}
if !strings.Contains(err.Error(), "deck.max_pages") {
t.Fatalf("error = %v, want deck.max_pages", err)
}
}
}

func TestLoadKeepsExplicitDeckThemeAndAuthor(t *testing.T) {
Expand Down
17 changes: 14 additions & 3 deletions internal/deck/deck.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,10 @@ func DefaultDeck(outDir string) Deck {
}

func FromJSON(raw string, outDir string) (Deck, error) {
return FromJSONWithMaxPages(raw, outDir, maxPages)
}

func FromJSONWithMaxPages(raw string, outDir string, configuredMaxPages int) (Deck, error) {
var rd rawDeck
if err := json.Unmarshal([]byte(raw), &rd); err != nil {
return Deck{}, fmt.Errorf("parse deck json: %w", err)
Expand Down Expand Up @@ -385,16 +389,23 @@ func FromJSON(raw string, outDir string) (Deck, error) {
Pages: pages,
Themes: defaultThemes(),
}
if err := d.Validate(); err != nil {
if err := d.ValidateWithMaxPages(configuredMaxPages); err != nil {
return Deck{}, err
}
d.Pages = normalizePageNames(d.Pages)
return d, nil
}

func (d Deck) Validate() error {
if len(d.Pages) < minPages || len(d.Pages) > maxPages {
return fmt.Errorf("deck must contain 3 to 12 pages")
return d.ValidateWithMaxPages(maxPages)
}

func (d Deck) ValidateWithMaxPages(configuredMaxPages int) error {
if configuredMaxPages == 0 {
configuredMaxPages = maxPages
}
if len(d.Pages) < minPages || len(d.Pages) > configuredMaxPages {
return fmt.Errorf("deck must contain %d to %d pages", minPages, configuredMaxPages)
}
if d.Pages[0].Variant != "cover" {
return fmt.Errorf("first page must use cover variant")
Expand Down