From b9006a4421b8964d4d6748ff8141752000cf9542 Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Sat, 6 Jun 2026 01:25:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(deck):=20=E6=94=AF=E6=8C=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9C=80=E5=A4=A7=E9=A1=B5=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.example.yaml | 2 + internal/ai/deck_builder.go | 20 ++++++-- internal/ai/deck_builder_test.go | 15 ++++++ internal/app/service.go | 8 +-- internal/app/service_test.go | 84 ++++++++++++++++++++++++++++++++ internal/config/config.go | 7 +++ internal/config/config_test.go | 40 +++++++++++++++ internal/deck/deck.go | 17 +++++-- 8 files changed, 181 insertions(+), 12 deletions(-) diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 4766863..37558ee 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -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 diff --git a/internal/ai/deck_builder.go b/internal/ai/deck_builder.go index 6cfeac8..e22c818 100644 --- a/internal/ai/deck_builder.go +++ b/internal/ai/deck_builder.go @@ -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 @@ -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) @@ -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) diff --git a/internal/ai/deck_builder_test.go b/internal/ai/deck_builder_test.go index 4924696..467f097 100644 --- a/internal/ai/deck_builder_test.go +++ b/internal/ai/deck_builder_test.go @@ -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} diff --git a/internal/app/service.go b/internal/app/service.go index 6fcd593..e19b1a5 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -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) } @@ -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) } @@ -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 @@ -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) } diff --git a/internal/app/service_test.go b/internal/app/service_test.go index e2cb845..b7272bf 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -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":"正文"}}]}`} @@ -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()}, @@ -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") diff --git a/internal/config/config.go b/internal/config/config.go index bffad7d..a9b33b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 11b8d8f..ef266e7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "reflect" @@ -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) { diff --git a/internal/deck/deck.go b/internal/deck/deck.go index 945d9d6..4fd99b4 100644 --- a/internal/deck/deck.go +++ b/internal/deck/deck.go @@ -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) @@ -385,7 +389,7 @@ 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) @@ -393,8 +397,15 @@ func FromJSON(raw string, outDir string) (Deck, error) { } 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")