From a447fe34e0659a3e68a53356f884e490a0bb46d7 Mon Sep 17 00:00:00 2001 From: walker <13750528578@163.com> Date: Wed, 10 Jun 2026 12:16:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(config):=20=E9=85=8D=E7=BD=AE=E5=8C=96=20AI?= =?UTF-8?q?=20=E9=87=8D=E8=AF=95=E7=AD=89=E5=BE=85=E5=BA=8F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/mark2note/main.go | 3 +++ configs/config.example.yaml | 7 +++++++ internal/ai/deck_builder.go | 29 +++++++++++++++++++------ internal/ai/deck_builder_test.go | 23 ++++++++++++++++++++ internal/ai/title_builder.go | 14 +++++++++---- internal/ai/topic_builder.go | 14 +++++++++---- internal/app/service.go | 1 + internal/config/config.go | 32 ++++++++++++++++++++++++++-- internal/config/config_test.go | 36 ++++++++++++++++++++++++++++++++ 9 files changed, 143 insertions(+), 16 deletions(-) diff --git a/cmd/mark2note/main.go b/cmd/mark2note/main.go index e6afe63..08aea06 100644 --- a/cmd/mark2note/main.go +++ b/cmd/mark2note/main.go @@ -674,18 +674,21 @@ var nowFunc = time.Now var buildDeckJSON = func(cfg *config.Config, markdown string) (string, error) { b := ai.Builder{} b.SetCommand(cfg.AI.Command, cfg.AI.Args) + b.SetRetryDelays(cfg.AI.Retry.Delays) return b.BuildDeckJSON(markdown) } var buildPublishTopics = func(cfg *config.Config, markdown string, title string) ([]string, error) { b := ai.TopicBuilder{} b.SetCommand(cfg.AI.Command, cfg.AI.Args) + b.SetRetryDelays(cfg.AI.Retry.Delays) return b.BuildPublishTopics(markdown, title) } var buildPublishTitle = func(cfg *config.Config, markdown string, title string, maxRunes int) (string, error) { b := ai.TitleBuilder{} b.SetCommand(cfg.AI.Command, cfg.AI.Args) + b.SetRetryDelays(cfg.AI.Retry.Delays) return b.BuildPublishTitle(markdown, title, maxRunes) } diff --git a/configs/config.example.yaml b/configs/config.example.yaml index bcc2db2..dda1d7c 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -6,6 +6,13 @@ ai: args: - codex - --bare + retry: + delays: + - 1s + - 2s + - 5s + - 9s + - 17s deck: # deck.theme_mode 可选值:fixed / weekly;weekly 会按本机星期选择 weekly_themes 中的固定主题。 diff --git a/internal/ai/deck_builder.go b/internal/ai/deck_builder.go index 6729e39..7e66978 100644 --- a/internal/ai/deck_builder.go +++ b/internal/ai/deck_builder.go @@ -64,6 +64,7 @@ type Builder struct { Args []string PromptExtra string MaxPages int + RetryDelays []time.Duration Runner CommandRunner } @@ -103,21 +104,33 @@ func (execRunner) Run(name string, args ...string) (string, string, error) { return "", "", err } -var aiCommandRetryDelays = []time.Duration{time.Second, 3 * time.Second} +var defaultAICommandRetryDelays = []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 9 * time.Second, 17 * time.Second} -func runAICommand(runner CommandRunner, name string, args ...string) (string, string, error) { +func cloneDurations(values []time.Duration) []time.Duration { + return append([]time.Duration(nil), values...) +} + +func effectiveAICommandRetryDelays(delays []time.Duration) []time.Duration { + if delays == nil { + return cloneDurations(defaultAICommandRetryDelays) + } + return cloneDurations(delays) +} + +func runAICommand(runner CommandRunner, name string, retryDelays []time.Duration, args ...string) (string, string, error) { var stdout string var stderr string var err error - for attempt := 0; attempt <= len(aiCommandRetryDelays); attempt++ { + delays := effectiveAICommandRetryDelays(retryDelays) + for attempt := 0; attempt <= len(delays); attempt++ { stdout, stderr, err = runner.Run(name, args...) if err == nil { return stdout, stderr, nil } - if attempt == len(aiCommandRetryDelays) || !isTransientAICommandError(err, stdout, stderr) { + if attempt == len(delays) || !isTransientAICommandError(err, stdout, stderr) { return stdout, stderr, err } - time.Sleep(aiCommandRetryDelays[attempt]) + time.Sleep(delays[attempt]) } return stdout, stderr, err } @@ -151,6 +164,10 @@ func (b *Builder) SetCommand(command string, args []string) { b.Args = append([]string(nil), args...) } +func (b *Builder) SetRetryDelays(delays []time.Duration) { + b.RetryDelays = cloneDurations(delays) +} + func (b Builder) effectiveRunner() CommandRunner { if b.Runner != nil { return b.Runner @@ -164,7 +181,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 := runAICommand(b.effectiveRunner(), b.Command, args...) + stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, b.RetryDelays, 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 93219b3..1c91af8 100644 --- a/internal/ai/deck_builder_test.go +++ b/internal/ai/deck_builder_test.go @@ -5,6 +5,7 @@ import ( "reflect" "strings" "testing" + "time" ) type fakeRunner struct { @@ -371,6 +372,7 @@ func TestBuildPublishTopicsRetriesTransientAILockError(t *testing.T) { }} b := TopicBuilder{Runner: runner} b.SetCommand("ccs", []string{"codex"}) + b.SetRetryDelays([]time.Duration{0}) got, err := b.BuildPublishTopics("# 标题", "标题") if err != nil { @@ -429,6 +431,7 @@ func TestBuildPublishTitleRetriesTransientAILockError(t *testing.T) { }} b := TitleBuilder{Runner: runner} b.SetCommand("ccs", []string{"codex"}) + b.SetRetryDelays([]time.Duration{0}) got, err := b.BuildPublishTitle("# 标题", "标题", 20) if err != nil { @@ -503,6 +506,7 @@ func TestBuildDeckJSONRetriesTransientAILockError(t *testing.T) { }} b := Builder{Runner: runner} b.SetCommand("ccs", []string{"codex"}) + b.SetRetryDelays([]time.Duration{0}) got, err := b.BuildDeckJSON("# title") if err != nil { @@ -516,6 +520,25 @@ func TestBuildDeckJSONRetriesTransientAILockError(t *testing.T) { } } +func TestBuildDeckJSONUsesConfiguredRetryDelays(t *testing.T) { + runner := &sequenceRunner{calls: []runnerCall{ + {stderr: "timeout", err: errors.New("exit status 1")}, + {stderr: "timeout", err: errors.New("exit status 1")}, + {stdout: `{"pages":[]}`}, + }} + b := Builder{Runner: runner} + b.SetCommand("custom-ai", nil) + b.SetRetryDelays([]time.Duration{0, 0}) + + _, err := b.BuildDeckJSON("# title") + if err != nil { + t.Fatalf("BuildDeckJSON() error = %v", err) + } + if runner.count != 3 { + t.Fatalf("runner calls = %d, want len(delays)+1", runner.count) + } +} + func TestBuildDeckJSONAppendsBareForCCSCodex(t *testing.T) { runner := &fakeRunner{stdout: `{"pages":[]}`} b := Builder{Runner: runner} diff --git a/internal/ai/title_builder.go b/internal/ai/title_builder.go index 7e4f4dc..e1903ab 100644 --- a/internal/ai/title_builder.go +++ b/internal/ai/title_builder.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" ) const publishTitlePromptTemplate = `你是一个小红书发布标题改写器。 @@ -24,9 +25,10 @@ Markdown 如下: %s` type TitleBuilder struct { - Command string - Args []string - Runner CommandRunner + Command string + Args []string + RetryDelays []time.Duration + Runner CommandRunner } type titleResponse struct { @@ -42,6 +44,10 @@ func (b *TitleBuilder) SetCommand(command string, args []string) { b.Args = append([]string(nil), args...) } +func (b *TitleBuilder) SetRetryDelays(delays []time.Duration) { + b.RetryDelays = cloneDurations(delays) +} + func (b TitleBuilder) effectiveRunner() CommandRunner { if b.Runner != nil { return b.Runner @@ -55,7 +61,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 := runAICommand(b.effectiveRunner(), b.Command, args...) + stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, b.RetryDelays, args...) if err != nil { return "", fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr) } diff --git a/internal/ai/topic_builder.go b/internal/ai/topic_builder.go index 7934244..f9a084f 100644 --- a/internal/ai/topic_builder.go +++ b/internal/ai/topic_builder.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" ) const topicPromptTemplate = `你是一个小红书发布话题生成器。 @@ -24,9 +25,10 @@ Markdown 如下: %s` type TopicBuilder struct { - Command string - Args []string - Runner CommandRunner + Command string + Args []string + RetryDelays []time.Duration + Runner CommandRunner } type topicResponse struct { @@ -42,6 +44,10 @@ func (b *TopicBuilder) SetCommand(command string, args []string) { b.Args = append([]string(nil), args...) } +func (b *TopicBuilder) SetRetryDelays(delays []time.Duration) { + b.RetryDelays = cloneDurations(delays) +} + func (b TopicBuilder) effectiveRunner() CommandRunner { if b.Runner != nil { return b.Runner @@ -55,7 +61,7 @@ func (b TopicBuilder) BuildPublishTopics(markdown, title string) ([]string, erro args = append(args, "--bare") } args = append(args, "-p", buildTopicPrompt(markdown, title)) - stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, args...) + stdout, stderr, err := runAICommand(b.effectiveRunner(), b.Command, b.RetryDelays, args...) if err != nil { return nil, fmt.Errorf("%w: %v\nstderr: %s", ErrAICommandFailed, err, stderr) } diff --git a/internal/app/service.go b/internal/app/service.go index 1e94075..a9783d5 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -582,6 +582,7 @@ func (s Service) effectiveBuildDeckJSON() func(*config.Config, string) (string, return func(cfg *config.Config, markdown string) (string, error) { b := ai.Builder{PromptExtra: s.PromptExtra, MaxPages: cfg.Deck.MaxPages, Runner: s.AICommandRunner} b.SetCommand(cfg.AI.Command, cfg.AI.Args) + b.SetRetryDelays(cfg.AI.Retry.Delays) return b.BuildDeckJSON(markdown) } } diff --git a/internal/config/config.go b/internal/config/config.go index 75463f6..5af2554 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,8 +23,13 @@ type OutputCfg struct { } type AICfg struct { - Command string `yaml:"command"` - Args []string `yaml:"args"` + Command string `yaml:"command"` + Args []string `yaml:"args"` + Retry AIRetryCfg `yaml:"retry"` +} + +type AIRetryCfg struct { + Delays []time.Duration `yaml:"delays"` } const ( @@ -188,6 +193,21 @@ func parseConfigDuration(value string) (time.Duration, error) { return parsed, nil } +var defaultAIRetryDelays = []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 9 * time.Second, 17 * time.Second} + +func cloneDurations(values []time.Duration) []time.Duration { + return append([]time.Duration(nil), values...) +} + +func validateAIRetryDelays(delays []time.Duration) error { + for i, delay := range delays { + if delay <= 0 { + return fmt.Errorf("validate ai.retry.delays[%d]: must be > 0", i) + } + } + return nil +} + var defaultDeckWeeklyThemes = map[string]string{ "mon": "default", "tue": "warm-paper", @@ -279,6 +299,14 @@ func Load(configPath string) (*Config, error) { if len(cfg.AI.Args) == 0 { cfg.AI.Args = []string{"codex", "--bare"} } + if cfg.AI.Retry.Delays == nil { + cfg.AI.Retry.Delays = cloneDurations(defaultAIRetryDelays) + } else { + cfg.AI.Retry.Delays = cloneDurations(cfg.AI.Retry.Delays) + } + if err := validateAIRetryDelays(cfg.AI.Retry.Delays); err != nil { + return nil, err + } if cfg.Deck.Theme == "" { cfg.Deck.Theme = "default" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6d2d155..e35d07b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -30,6 +30,42 @@ func TestLoadAppliesDefaultAIConfig(t *testing.T) { if !reflect.DeepEqual(cfg.AI.Args, []string{"codex", "--bare"}) { t.Fatalf("AI.Args = %v, want %v", cfg.AI.Args, []string{"codex", "--bare"}) } + wantRetryDelays := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 9 * time.Second, 17 * time.Second} + if !reflect.DeepEqual(cfg.AI.Retry.Delays, wantRetryDelays) { + t.Fatalf("AI.Retry.Delays = %v, want %v", cfg.AI.Retry.Delays, wantRetryDelays) + } +} + +func TestLoadParsesAIRetryDelays(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + content := "ai:\n retry:\n delays:\n - 2s\n - 11s\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) + } + want := []time.Duration{2 * time.Second, 11 * time.Second} + if !reflect.DeepEqual(cfg.AI.Retry.Delays, want) { + t.Fatalf("AI.Retry.Delays = %v, want %v", cfg.AI.Retry.Delays, want) + } +} + +func TestLoadRejectsInvalidAIRetryDelay(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + content := "ai:\n retry:\n delays:\n - 0s\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := Load(path) + if err == nil || !strings.Contains(err.Error(), "ai.retry.delays[0]") { + t.Fatalf("Load() error = %v, want ai.retry.delays[0] validation error", err) + } } func TestLoadAppliesDefaultDeckConfig(t *testing.T) {