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
53 changes: 49 additions & 4 deletions internal/ai/deck_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os/exec"
"slices"
"strings"
"time"
)

const defaultMaxPages = 12
Expand All @@ -24,9 +25,9 @@ const deckPromptConstraintsTemplate = `你是一个严格的 JSON 生成器。
8. meta.theme 保留为兼容字段,可使用 default;实际整套卡片颜色由顶层 theme 决定
9. variant 只能使用:cover、quote、image-caption、text-caption、bullets、compare、gallery-steps、ending
10. content 字段按 variant 严格约束:
- cover 只能使用 title/subtitle,且 cover 的 title 必填
- cover 只能使用 title/subtitle/images,且 cover 的 title 必填;如果提供 images,最多 1 项,每个 image 都必须包含 src 和 alt
- quote 只能使用 title/quote/note/tip,且 quote 的 title 和 quote 必填
- image-caption 只能使用 title/body/images,且 image-caption 的 title 必填、image-caption 的 images 必须正好 1 项,每个 image 都必须包含 src 和 alt
- image-caption 只能使用 title/body/images,且 image-caption 的 title 必填、image-caption 的 images 最多 1 项、image-caption 必须提供 body 或 images,每个 image 都必须包含 src 和 alt
- text-caption 只能使用 title/body/tip,且 text-caption 的 title 和 body 必填
- bullets 只能使用 title/items,且 bullets 的 title 必填、items 至少 1 项
- compare 只能使用 title/compare,且 compare 的 title 必填、compare 必须使用 compare{leftLabel,rightLabel,rows}、compare.leftLabel/rightLabel 必填、rows 至少 1 项,且每个 rows 项都必须包含 left 和 right
Expand All @@ -42,7 +43,8 @@ const deckPromptConstraintsTemplate = `你是一个严格的 JSON 生成器。
18. 每页可见内容必须完整放进 1242x1656 竖版卡片,不能依赖浏览器裁切
19. 长正文和长代码块必须保留原文完整内容,不要用省略号、省略说明或伪代码替代 fenced code block
20. 需要容纳长内容时优先选择 text-caption、image-caption 或 ending,由渲染层缩小字号和间距;不要为了排版删减原文代码
21. fenced code block 过长时不要强行塞进单页;拆成连续的 text-caption 或 image-caption 页面,每页保留连续、完整、可执行的原始代码片段,不得用省略号替代被拆分的代码`
21. fenced code block 过长时不要强行塞进单页;拆成连续的 text-caption 或 image-caption 页面,每页保留连续、完整、可执行的原始代码片段,不得用省略号替代被拆分的代码
22. Markdown 开头第一张 alt 包含“封面”或 cover 的图应放入第一页 cover.images,作为视觉封面;不要再把同一张图重复放入后续 image-caption 页面`

const deckPromptMarkdownFooter = "\n\nMarkdown 如下:\n"
const promptExtraIntro = "以下是本次生成的额外约束,只能用于控制风格、安全边界和取舍;不得原文复制、不得改写、不得概括到 JSON 的任何可见字段里,包括 title、subtitle、body、quote、note、tip、cta、items、steps、compare、images.alt。可见文案只能来自 Markdown 原文:"
Expand Down Expand Up @@ -101,6 +103,49 @@ func (execRunner) Run(name string, args ...string) (string, string, error) {
return "", "", err
}

var aiCommandRetryDelays = []time.Duration{time.Second, 3 * time.Second}

func runAICommand(runner CommandRunner, name string, args ...string) (string, string, error) {
var stdout string
var stderr string
var err error
for attempt := 0; attempt <= len(aiCommandRetryDelays); attempt++ {
stdout, stderr, err = runner.Run(name, args...)
if err == nil {
return stdout, stderr, nil
}
if attempt == len(aiCommandRetryDelays) || !isTransientAICommandError(err, stdout, stderr) {
return stdout, stderr, err
}
time.Sleep(aiCommandRetryDelays[attempt])
}
return stdout, stderr, err
}

func isTransientAICommandError(err error, stdout, stderr string) bool {
if err == nil {
return false
}
combined := strings.ToLower(strings.Join([]string{err.Error(), stdout, stderr}, "\n"))
for _, marker := range []string{
"lock file is already being held",
"failed to acquire startup lock",
"another ccs process may be starting cliproxy",
"elocked",
"enotacquired",
"temporarily unavailable",
"timeout",
"timed out",
"connection reset",
"connection refused",
} {
if strings.Contains(combined, marker) {
return true
}
}
return false
}

func (b *Builder) SetCommand(command string, args []string) {
b.Command = command
b.Args = append([]string(nil), args...)
Expand All @@ -119,7 +164,7 @@ func (b Builder) BuildDeckJSON(markdown string) (string, error) {
args = append(args, "--bare")
}
args = append(args, "-p", buildDeckPromptWithMaxPages(markdown, b.PromptExtra, b.MaxPages))
stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...)
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...)
if err != nil {
return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
}
Expand Down
97 changes: 92 additions & 5 deletions internal/ai/deck_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ func (r *fakeRunner) Run(name string, args ...string) (string, string, error) {
return r.stdout, r.stderr, r.err
}

type runnerCall struct {
stdout string
stderr string
err error
}

type sequenceRunner struct {
calls []runnerCall
count int
}

func (r *sequenceRunner) Run(_ string, _ ...string) (string, string, error) {
if r.count >= len(r.calls) {
return "", "", errors.New("unexpected runner call")
}
call := r.calls[r.count]
r.count++
return call.stdout, call.stderr, call.err
}

func TestBuildDeckPromptKeepsLegacyLayoutWhenPromptExtraEmpty(t *testing.T) {
got := buildDeckPrompt("# 标题", "")
if !strings.Contains(got, "Markdown 如下:\n# 标题") {
Expand Down Expand Up @@ -128,12 +148,15 @@ func TestBuildDeckJSONUsesConfiguredCommand(t *testing.T) {
if strings.Contains(prompt, "meta.theme 只能使用 orange 或 green") {
t.Fatalf("prompt still contains old visual meta.theme constraint: %q", prompt)
}
if !strings.Contains(prompt, "cover 只能使用 title/subtitle") {
if !strings.Contains(prompt, "cover 只能使用 title/subtitle/images") {
t.Fatalf("prompt missing cover content whitelist: %q", prompt)
}
if !strings.Contains(prompt, "cover 的 title 必填") {
t.Fatalf("prompt missing cover required title rule: %q", prompt)
}
if !strings.Contains(prompt, "Markdown 开头第一张 alt 包含“封面”或 cover 的图应放入第一页 cover.images") {
t.Fatalf("prompt missing first Markdown cover image guidance: %q", prompt)
}
if !strings.Contains(prompt, "quote 只能使用 title/quote/note/tip") {
t.Fatalf("prompt missing quote content whitelist: %q", prompt)
}
Expand All @@ -149,11 +172,14 @@ func TestBuildDeckJSONUsesConfiguredCommand(t *testing.T) {
if !strings.Contains(prompt, "image-caption 的 title 必填") {
t.Fatalf("prompt missing image-caption required title rule: %q", prompt)
}
if !strings.Contains(prompt, "image-caption 的 images 必须正好 1 项") {
t.Fatalf("prompt missing image-caption exact image count: %q", prompt)
if !strings.Contains(prompt, "image-caption 的 images 最多 1 项") {
t.Fatalf("prompt missing image-caption max image count: %q", prompt)
}
if !strings.Contains(prompt, "image-caption 必须提供 body 或 images") {
t.Fatalf("prompt missing image-caption body-or-image rule: %q", prompt)
}
if strings.Contains(prompt, "image-caption 最多 1 张图") {
t.Fatalf("prompt should not allow image-caption without an image: %q", prompt)
if strings.Contains(prompt, "image-caption 的 images 必须正好 1 项") {
t.Fatalf("prompt should allow image-caption without an image when body exists: %q", prompt)
}
if !strings.Contains(prompt, "每个 image 都必须包含 src 和 alt") {
t.Fatalf("prompt missing image src/alt rule: %q", prompt)
Expand Down Expand Up @@ -338,6 +364,27 @@ func TestBuildPublishTopicsRejectsInvalidJSON(t *testing.T) {
}
}

func TestBuildPublishTopicsRetriesTransientAILockError(t *testing.T) {
runner := &sequenceRunner{calls: []runnerCall{
{stderr: "[X] Lock file is already being held", err: errors.New("exit status 1")},
{stdout: `{"topics":["AI编程"]}`},
}}
b := TopicBuilder{Runner: runner}
b.SetCommand("ccs", []string{"codex"})

got, err := b.BuildPublishTopics("# 标题", "标题")
if err != nil {
t.Fatalf("BuildPublishTopics() error = %v", err)
}
want := []string{"AI编程"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("BuildPublishTopics() = %#v, want %#v", got, want)
}
if runner.count != 2 {
t.Fatalf("runner calls = %d, want 2", runner.count)
}
}

func TestBuildPublishTitleUsesConfiguredCommand(t *testing.T) {
runner := &fakeRunner{stdout: `{"title":"把代码合进真实开源"}`}
b := TitleBuilder{Runner: runner}
Expand Down Expand Up @@ -375,6 +422,26 @@ func TestBuildPublishTitleRejectsInvalidJSON(t *testing.T) {
}
}

func TestBuildPublishTitleRetriesTransientAILockError(t *testing.T) {
runner := &sequenceRunner{calls: []runnerCall{
{stderr: "[X] Lock file is already being held", err: errors.New("exit status 1")},
{stdout: `{"title":"短标题"}`},
}}
b := TitleBuilder{Runner: runner}
b.SetCommand("ccs", []string{"codex"})

got, err := b.BuildPublishTitle("# 标题", "标题", 20)
if err != nil {
t.Fatalf("BuildPublishTitle() error = %v", err)
}
if got != "短标题" {
t.Fatalf("BuildPublishTitle() = %q, want retry title", got)
}
if runner.count != 2 {
t.Fatalf("runner calls = %d, want 2", runner.count)
}
}

func TestBuildPublishTitleReturnsStderrOnRunnerError(t *testing.T) {
runner := &fakeRunner{stderr: "boom", err: errors.New("exit status 1")}
b := TitleBuilder{Runner: runner}
Expand Down Expand Up @@ -429,6 +496,26 @@ func TestBuildDeckJSONReturnsStderrOnRunnerError(t *testing.T) {
}
}

func TestBuildDeckJSONRetriesTransientAILockError(t *testing.T) {
runner := &sequenceRunner{calls: []runnerCall{
{stderr: "[X] Lock file is already being held", err: errors.New("exit status 1")},
{stdout: `{"pages":[]}`},
}}
b := Builder{Runner: runner}
b.SetCommand("ccs", []string{"codex"})

got, err := b.BuildDeckJSON("# title")
if err != nil {
t.Fatalf("BuildDeckJSON() error = %v", err)
}
if got != `{"pages":[]}` {
t.Fatalf("BuildDeckJSON() = %q, want JSON from retry", got)
}
if runner.count != 2 {
t.Fatalf("runner calls = %d, want 2", runner.count)
}
}

func TestBuildDeckJSONAppendsBareForCCSCodex(t *testing.T) {
runner := &fakeRunner{stdout: `{"pages":[]}`}
b := Builder{Runner: runner}
Expand Down
2 changes: 1 addition & 1 deletion internal/ai/title_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (b TitleBuilder) BuildPublishTitle(markdown, title string, maxRunes int) (s
args = append(args, "--bare")
}
args = append(args, "-p", buildPublishTitlePrompt(markdown, title, maxRunes))
stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...)
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...)
if err != nil {
return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/ai/topic_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (b TopicBuilder) BuildPublishTopics(markdown, title string) ([]string, erro
args = append(args, "--bare")
}
args = append(args, "-p", buildTopicPrompt(markdown, title))
stdout, stderr, err := b.effectiveRunner().Run(b.Command, args...)
stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...)
if err != nil {
return nil, fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr)
}
Expand Down
69 changes: 69 additions & 0 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -156,6 +157,7 @@ func (s Service) GeneratePreview(opts Options) (Result, error) {
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrParseDeck, err)
}
d = moveLeadingMarkdownImageToCover(d, string(markdownBytes))
var posterWarnings []string
if d, posterWarnings, err = s.hydratePosters(opts, d, string(markdownBytes)); err != nil {
return Result{}, err
Expand Down Expand Up @@ -284,6 +286,73 @@ func appendMissingPosterWarning(warnings []string, title string) []string {
return append(warnings, warning)
}

var markdownImagePattern = regexp.MustCompile(`!\[([^\]]*)\]\(([^)\s]+)(?:\s+[^)]*)?\)`)

func moveLeadingMarkdownImageToCover(d deck.Deck, markdown string) deck.Deck {
if len(d.Pages) == 0 || d.Pages[0].Variant != "cover" || len(d.Pages[0].Content.Images) > 0 {
return d
}
image, ok := leadingMarkdownImage(markdown)
if !ok {
return d
}
coverImage := image
for pageIndex := 1; pageIndex < len(d.Pages); pageIndex++ {
images := d.Pages[pageIndex].Content.Images
kept := images[:0]
for _, existing := range images {
if strings.TrimSpace(existing.Src) == image.Src {
if strings.TrimSpace(existing.Alt) != "" {
coverImage.Alt = strings.TrimSpace(existing.Alt)
}
continue
}
kept = append(kept, existing)
}
if len(kept) != len(images) {
d.Pages[pageIndex].Content.Images = kept
if d.Pages[pageIndex].Variant == "image-caption" && strings.TrimSpace(d.Pages[pageIndex].Content.Body) == "" {
d.Pages[pageIndex].Content.Body = coverImage.Alt
}
}
}
d.Pages[0].Content.Images = []deck.ImageBlock{coverImage}
return d
}

func leadingMarkdownImage(markdown string) (deck.ImageBlock, bool) {
match := markdownImagePattern.FindStringSubmatchIndex(markdown)
if match == nil {
return deck.ImageBlock{}, false
}
prefix := markdown[:match[0]]
for _, line := range strings.Split(prefix, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "# ") {
continue
}
return deck.ImageBlock{}, false
}
parts := markdownImagePattern.FindStringSubmatch(markdown[match[0]:match[1]])
if len(parts) < 3 {
return deck.ImageBlock{}, false
}
alt := strings.TrimSpace(parts[1])
if !isCoverImageAlt(alt) {
return deck.ImageBlock{}, false
}
src := strings.TrimSpace(parts[2])
if src == "" {
return deck.ImageBlock{}, false
}
return deck.ImageBlock{Src: src, Alt: alt}, true
}

func isCoverImageAlt(alt string) bool {
alt = strings.ToLower(strings.TrimSpace(alt))
return strings.Contains(alt, "封面") || strings.Contains(alt, "cover")
}

func hydrateLocalImageAssets(d deck.Deck, baseDir string) deck.Deck {
baseDir = strings.TrimSpace(baseDir)
if baseDir == "" {
Expand Down
Loading