From 75bdda1fe3afeb50cee132428fb48341305e1d6b Mon Sep 17 00:00:00 2001 From: voocel Date: Sun, 8 Mar 2026 12:02:46 +0800 Subject: [PATCH] feat: support tui --- app/config.go | 27 +++- app/run.go | 347 ++++++++++++++++++++++++++++++------------------- app/runtime.go | 333 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 32 ++++- go.sum | 56 ++++++++ main.go | 52 +++++--- tui/app.go | 50 +++++++ tui/events.go | 82 ++++++++++++ tui/input.go | 14 ++ tui/layout.go | 53 ++++++++ tui/model.go | 286 ++++++++++++++++++++++++++++++++++++++++ tui/panels.go | 187 ++++++++++++++++++++++++++ tui/theme.go | 68 ++++++++++ 13 files changed, 1435 insertions(+), 152 deletions(-) create mode 100644 app/runtime.go create mode 100644 tui/app.go create mode 100644 tui/events.go create mode 100644 tui/input.go create mode 100644 tui/layout.go create mode 100644 tui/model.go create mode 100644 tui/panels.go create mode 100644 tui/theme.go diff --git a/app/config.go b/app/config.go index c6ed351..93d14d2 100644 --- a/app/config.go +++ b/app/config.go @@ -10,6 +10,7 @@ type Config struct { Prompt string // 用户的小说需求 NovelName string // 小说名(用作输出目录名) OutputDir string // 输出根目录,默认 output/{NovelName} + Provider string // LLM 提供商:openai / anthropic / gemini ModelName string // LLM 模型名 APIKey string // API Key BaseURL string // API Base URL(可选) @@ -25,17 +26,34 @@ type Prompts struct { Editor string } -// Validate 校验配置。 +// Validate 校验配置(CLI 模式,要求 Prompt 非空)。 func (c *Config) Validate() error { if c.Prompt == "" { return fmt.Errorf("prompt is required") } + return c.ValidateBase() +} + +// ValidateBase 校验基础配置(TUI 模式下 Prompt 由用户输入,不在此检查)。 +func (c *Config) ValidateBase() error { if c.APIKey == "" { - return fmt.Errorf("api key is required (set OPENAI_API_KEY)") + return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)") + } + switch c.Provider { + case "openai", "anthropic", "gemini": + default: + return fmt.Errorf("unsupported provider %q (use openai/anthropic/gemini)", c.Provider) } return nil } +// 各 provider 的默认模型名。 +var defaultModels = map[string]string{ + "openai": "gpt-4o", + "anthropic": "claude-sonnet-4-20250514", + "gemini": "gemini-2.5-pro", +} + // FillDefaults 填充默认值。 func (c *Config) FillDefaults() { if c.NovelName == "" { @@ -44,8 +62,11 @@ func (c *Config) FillDefaults() { if c.OutputDir == "" { c.OutputDir = filepath.Join("output", c.NovelName) } + if c.Provider == "" { + c.Provider = "openai" + } if c.ModelName == "" { - c.ModelName = "gpt-4o" + c.ModelName = defaultModels[c.Provider] } if c.Style == "" { c.Style = "default" diff --git a/app/run.go b/app/run.go index 3013657..488d5b4 100644 --- a/app/run.go +++ b/app/run.go @@ -16,7 +16,11 @@ import ( "github.com/voocel/ainovel-cli/tools" ) -// Run 启动小说创作流程。 +// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件。 +// CLI 模式下为 nil,Runtime 模式下指向 events channel。 +type emitFn func(UIEvent) + +// Run 启动小说创作流程(CLI 模式,阻塞直到完成)。 func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error { cfg.FillDefaults() if err := cfg.Validate(); err != nil { @@ -30,11 +34,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s } // 2. 创建模型 - var baseURL []string - if cfg.BaseURL != "" { - baseURL = append(baseURL, cfg.BaseURL) - } - model, err := llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) + model, err := createModel(cfg) if err != nil { return fmt.Errorf("create model: %w", err) } @@ -43,33 +43,7 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles) // 4. 确定性控制面:事件监听 + FollowUp 注入 - coordinator.Subscribe(func(ev agentcore.Event) { - switch ev.Type { - case agentcore.EventToolExecStart: - log.Printf("[tool:start] %s", ev.Tool) - - case agentcore.EventToolExecEnd: - if ev.IsError { - log.Printf("[tool:error] %s", ev.Tool) - return - } - log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200)) - - // 宿主确定性控制:SubAgent 完成后读取信号文件 - if ev.Tool == "subagent" { - handleSubAgentDone(coordinator, store, cfg.MaxChapters) - handleEditorDone(coordinator, store) - } - - case agentcore.EventMessageEnd: - if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant { - log.Printf("[assistant] %s", truncateLog(ev.Message.TextContent(), 300)) - } - - case agentcore.EventError: - log.Printf("[error] %v", ev.Err) - } - }) + registerSubscription(coordinator, store, cfg.MaxChapters, nil) // 5. 初始化运行元信息(保留已有 SteerHistory) if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { @@ -84,98 +58,36 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s if text == "" { continue } - log.Printf("[steer] 用户干预: %s", text) - if err := store.AppendSteerEntry(domain.SteerEntry{ - Input: text, - Timestamp: time.Now().Format(time.RFC3339), - }); err != nil { - log.Printf("[warn] 追加干预记录失败: %v", err) - } - if err := store.SetPendingSteer(text); err != nil { - log.Printf("[warn] 设置待处理干预失败: %v", err) - } - if err := store.SetFlow(domain.FlowSteering); err != nil { - log.Printf("[warn] 设置流程状态失败: %v", err) - } - coordinator.Steer(agentcore.UserMsg(fmt.Sprintf( - "[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text))) + submitSteer(store, coordinator, text) } }() - // 7. 恢复或启动(按优先级链) + // 7. 恢复或启动 progress, _ := store.LoadProgress() runMeta, _ := store.LoadRunMeta() - if progress != nil && progress.InProgressChapter > 0 { - // 场景级恢复:章节写到一半 - ch := progress.InProgressChapter - scenes := len(progress.CompletedScenes) - log.Printf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes) - if err := coordinator.Prompt(fmt.Sprintf( - "第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", - ch, scenes, scenes+1, progress.TotalChapters, - )); err != nil { - return fmt.Errorf("prompt: %w", err) - } - } else if progress != nil && len(progress.PendingRewrites) > 0 { - // 重写恢复:有待重写章节 - log.Printf("重写恢复:%d 章待处理 %v", len(progress.PendingRewrites), progress.PendingRewrites) - verb := "重写" - if progress.Flow == domain.FlowPolishing { - verb = "打磨" - } - if err := coordinator.Prompt(fmt.Sprintf( - "有 %d 章待%s(受影响章节:%v)。原因:%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。", - len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters, - )); err != nil { - return fmt.Errorf("prompt: %w", err) - } - } else if progress != nil && progress.Flow == domain.FlowReviewing { - // 审阅恢复:审阅中断 - log.Printf("审阅恢复:上次审阅中断") - if err := coordinator.Prompt(fmt.Sprintf( - "上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。", - len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, - )); err != nil { - return fmt.Errorf("prompt: %w", err) - } - } else if progress != nil && progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" { - next := progress.NextChapter() - log.Printf("Steer 恢复:上次干预未完成,重新注入") - if err := coordinator.Prompt(fmt.Sprintf( - "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", - next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer, - )); err != nil { - return fmt.Errorf("prompt: %w", err) - } - } else if progress != nil && progress.IsResumable() { - next := progress.NextChapter() - log.Printf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)", - next, len(progress.CompletedChapters), progress.TotalWordCount) - if err := coordinator.Prompt(fmt.Sprintf( - "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。", - next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, - )); err != nil { - return fmt.Errorf("prompt: %w", err) - } - } else { - // 新建:初始化进度 + recovery := determineRecovery(progress, runMeta, cfg.MaxChapters) + + if recovery.IsNew { if err := store.InitProgress(cfg.NovelName, cfg.MaxChapters); err != nil { return fmt.Errorf("init progress: %w", err) } log.Printf("新建模式:%s(%d 章)", cfg.NovelName, cfg.MaxChapters) - if err := coordinator.Prompt(fmt.Sprintf( - "请创作一部 %d 章的小说。要求如下:\n\n%s", - cfg.MaxChapters, cfg.Prompt, - )); err != nil { + promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", cfg.MaxChapters, cfg.Prompt) + if err := coordinator.Prompt(promptText); err != nil { + return fmt.Errorf("prompt: %w", err) + } + } else { + log.Printf("%s", recovery.Label) + if err := coordinator.Prompt(recovery.PromptText); err != nil { return fmt.Errorf("prompt: %w", err) } } - // 6. 等待完成 + // 8. 等待完成 coordinator.WaitForIdle() finalizeSteerIfIdle(store) - // 7. 输出结果 + // 9. 输出结果 finalProgress, _ := store.LoadProgress() if finalProgress != nil { log.Printf("创作完成:%d 章,共 %d 字,输出目录:%s", @@ -184,20 +96,161 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s return nil } +// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent 转发。 +func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) { + coordinator.Subscribe(func(ev agentcore.Event) { + switch ev.Type { + case agentcore.EventToolExecStart: + log.Printf("[tool:start] %s", ev.Tool) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".start", Level: "info"}) + } + + case agentcore.EventToolExecEnd: + if ev.IsError { + log.Printf("[tool:error] %s", ev.Tool) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: ev.Tool + " 执行失败", Level: "error"}) + } + return + } + log.Printf("[tool:done] %s → %s", ev.Tool, truncateLog(string(ev.Result), 200)) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".done", Level: "info"}) + } + + if ev.Tool == "subagent" { + handleSubAgentDone(coordinator, store, maxChapters, emit) + handleEditorDone(coordinator, store, emit) + } + + case agentcore.EventMessageEnd: + if ev.Message != nil && ev.Message.GetRole() == agentcore.RoleAssistant { + text := truncateLog(ev.Message.TextContent(), 300) + log.Printf("[assistant] %s", text) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "AGENT", Summary: truncateLog(ev.Message.TextContent(), 80), Level: "info"}) + } + } + + case agentcore.EventError: + log.Printf("[error] %v", ev.Err) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "ERROR", Summary: fmt.Sprintf("%v", ev.Err), Level: "error"}) + } + } + }) +} + +// submitSteer 提交用户干预(CLI 和 Runtime 共用)。 +func submitSteer(store *state.Store, coordinator *agentcore.Agent, text string) { + log.Printf("[steer] 用户干预: %s", text) + if err := store.AppendSteerEntry(domain.SteerEntry{ + Input: text, + Timestamp: time.Now().Format(time.RFC3339), + }); err != nil { + log.Printf("[warn] 追加干预记录失败: %v", err) + } + if err := store.SetPendingSteer(text); err != nil { + log.Printf("[warn] 设置待处理干预失败: %v", err) + } + if err := store.SetFlow(domain.FlowSteering); err != nil { + log.Printf("[warn] 设置流程状态失败: %v", err) + } + coordinator.Steer(agentcore.UserMsg(fmt.Sprintf( + "[用户干预] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", text))) +} + +// recoveryResult 恢复链的判断结果。 +type recoveryResult struct { + PromptText string // 恢复时的 Prompt 文本 + Label string // 恢复类型描述(供日志和 TUI 显示) + IsNew bool // true 表示新建模式 +} + +// determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。 +func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta, maxChapters int) recoveryResult { + if progress == nil { + return recoveryResult{IsNew: true} + } + + if progress.InProgressChapter > 0 { + ch := progress.InProgressChapter + scenes := len(progress.CompletedScenes) + return recoveryResult{ + PromptText: fmt.Sprintf( + "第 %d 章正在进行中,已完成 %d 个场景。请调用 writer 从场景 %d 继续写作。总共需要写 %d 章。", + ch, scenes, scenes+1, progress.TotalChapters), + Label: fmt.Sprintf("场景级恢复:第 %d 章已完成 %d 个场景", ch, scenes), + } + } + + if len(progress.PendingRewrites) > 0 { + verb := "重写" + if progress.Flow == domain.FlowPolishing { + verb = "打磨" + } + return recoveryResult{ + PromptText: fmt.Sprintf( + "有 %d 章待%s(受影响章节:%v)。原因:%s。请逐章调用 writer %s后继续正常写作。总共需要写 %d 章。", + len(progress.PendingRewrites), verb, progress.PendingRewrites, progress.RewriteReason, verb, progress.TotalChapters), + Label: fmt.Sprintf("%s恢复:%d 章待处理 %v", verb, len(progress.PendingRewrites), progress.PendingRewrites), + } + } + + if progress.Flow == domain.FlowReviewing { + return recoveryResult{ + PromptText: fmt.Sprintf( + "上次审阅中断,请重新调用 editor 对已完成章节进行全局审阅。已完成 %d 章,共 %d 字。总共需要写 %d 章。", + len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters), + Label: "审阅恢复:上次审阅中断", + } + } + + if progress.IsResumable() && runMeta != nil && runMeta.PendingSteer != "" { + next := progress.NextChapter() + return recoveryResult{ + PromptText: fmt.Sprintf( + "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。\n\n[用户干预-恢复] %s\n请评估影响范围,决定是否需要修改设定或重写已有章节。", + next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters, runMeta.PendingSteer), + Label: "Steer 恢复:上次干预未完成,重新注入", + } + } + + if progress.IsResumable() { + next := progress.NextChapter() + return recoveryResult{ + PromptText: fmt.Sprintf( + "从第 %d 章继续写作。之前已完成 %d 章,共 %d 字。总共需要写 %d 章。", + next, len(progress.CompletedChapters), progress.TotalWordCount, progress.TotalChapters), + Label: fmt.Sprintf("恢复模式:从第 %d 章继续(已完成 %d 章,共 %d 字)", + next, len(progress.CompletedChapters), progress.TotalWordCount), + } + } + + return recoveryResult{IsNew: true} +} + // handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。 -// SubAgent 内部工具事件不冒泡,所以通过 meta/last_commit.json 传递信号。 -func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int) { +func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) { result, err := store.LoadLastCommit() if err != nil || result == nil { - return // 不是 Writer 的 commit,可能是 Architect 的 SubAgent 调用 + return } - // 消费即清除,防止重复注入 FollowUp if err := store.ClearLastCommit(); err != nil { log.Printf("[host] 清除 commit 信号失败: %v", err) } log.Printf("[host] 章节提交信号:第 %d 章,%d 字,%d 个场景", result.Chapter, result.WordCount, result.SceneCount) + if emit != nil { + emit(UIEvent{ + Time: time.Now(), + Category: "SYSTEM", + Summary: fmt.Sprintf("第 %d 章已提交:%d 字,%d 个场景", result.Chapter, result.WordCount, result.SceneCount), + Level: "success", + }) + } // 确定性判断 0:正在重写/打磨流程中 progress, _ := store.LoadProgress() @@ -216,19 +269,16 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha updated, _ := store.LoadProgress() if updated != nil && len(updated.PendingRewrites) == 0 { log.Printf("[host] 所有重写/打磨已完成,恢复正常写作") - if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { - log.Printf("[host] 保存检查点失败: %v", err) - } - if err := store.SaveCheckpoint("rewrite-done"); err != nil { - log.Printf("[host] 保存检查点失败: %v", err) + saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) + saveCheckpoint(store, "rewrite-done") + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "所有重写/打磨已完成", Level: "success"}) } } else if updated != nil { log.Printf("[host] 还有 %d 章待处理:%v", len(updated.PendingRewrites), updated.PendingRewrites) - if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { - log.Printf("[host] 保存检查点失败: %v", err) - } + saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) } - return // 重写期间不触发全书完成/审阅判断 + return } // 确定性判断 1:全书完成 @@ -238,8 +288,9 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha log.Printf("[host] 标记完成失败: %v", err) } clearHandledSteer(store) - if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { - log.Printf("[host] 保存检查点失败: %v", err) + saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("全部 %d 章已完成", maxChapters), Level: "success"}) } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。", @@ -253,18 +304,19 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha if err := store.SetFlow(domain.FlowReviewing); err != nil { log.Printf("[host] 设置审阅流程失败: %v", err) } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "review_required=true " + result.ReviewReason, Level: "warn"}) + } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] review_required=true,%s。请调用 editor 对已完成章节进行全局审阅,然后根据审阅结果决定继续写第 %d 章还是修正已有章节。", result.ReviewReason, result.NextChapter))) } clearHandledSteer(store) - if err := store.SaveCheckpoint(fmt.Sprintf("ch%02d-commit", result.Chapter)); err != nil { - log.Printf("[host] 保存检查点失败: %v", err) - } + saveCheckpoint(store, fmt.Sprintf("ch%02d-commit", result.Chapter)) } // handleEditorDone 在 Editor SubAgent 完成后读取审阅信号。 -func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) { +func handleEditorDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) { review, err := store.LoadLastReviewSignal() if err != nil { log.Printf("[host] 加载审阅信号失败: %v", err) @@ -273,7 +325,6 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) { if review == nil { return } - // 消费即清除,防止重复注入 FollowUp if err := store.ClearLastReview(); err != nil { log.Printf("[host] 清除审阅信号失败: %v", err) } @@ -293,6 +344,10 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) { if err := store.SetFlow(domain.FlowRewriting); err != nil { log.Printf("[host] 设置流程状态失败: %v", err) } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "REVIEW", + Summary: fmt.Sprintf("verdict=rewrite affected=%v", review.AffectedChapters), Level: "warn"}) + } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] Editor 审阅结论:rewrite。%s%s请逐章调用 writer 重写受影响章节,全部完成后继续正常写作。", review.Summary, chaptersInfo))) @@ -303,17 +358,31 @@ func handleEditorDone(coordinator *agentcore.Agent, store *state.Store) { if err := store.SetFlow(domain.FlowPolishing); err != nil { log.Printf("[host] 设置流程状态失败: %v", err) } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "REVIEW", + Summary: fmt.Sprintf("verdict=polish affected=%v", review.AffectedChapters), Level: "warn"}) + } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] Editor 审阅结论:polish。%s%s请逐章调用 writer 打磨受影响章节,全部完成后继续正常写作。", review.Summary, chaptersInfo))) default: - // accept — 审阅通过,清除审阅状态 if err := store.SetFlow(domain.FlowWriting); err != nil { log.Printf("[host] 清除审阅状态失败: %v", err) } + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "REVIEW", Summary: "verdict=accept 审阅通过", Level: "success"}) + } } clearHandledSteer(store) - if err := store.SaveCheckpoint(fmt.Sprintf("review-ch%02d-%s", review.Chapter, review.Verdict)); err != nil { + saveCheckpoint(store, fmt.Sprintf("review-ch%02d-%s", review.Chapter, review.Verdict)) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "CHECK", + Summary: fmt.Sprintf("saved review-ch%02d-%s", review.Chapter, review.Verdict), Level: "info"}) + } +} + +func saveCheckpoint(store *state.Store, label string) { + if err := store.SaveCheckpoint(label); err != nil { log.Printf("[host] 保存检查点失败: %v", err) } } @@ -349,3 +418,19 @@ func finalizeSteerIfIdle(store *state.Store) { } clearHandledSteer(store) } + +// createModel 根据 provider 创建对应的 LLM 模型。 +func createModel(cfg Config) (agentcore.ChatModel, error) { + var baseURL []string + if cfg.BaseURL != "" { + baseURL = append(baseURL, cfg.BaseURL) + } + switch cfg.Provider { + case "anthropic": + return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...) + case "gemini": + return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...) + default: + return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) + } +} diff --git a/app/runtime.go b/app/runtime.go new file mode 100644 index 0000000..52e16a1 --- /dev/null +++ b/app/runtime.go @@ -0,0 +1,333 @@ +package app + +import ( + "fmt" + "log" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/voocel/agentcore" + "github.com/voocel/ainovel-cli/domain" + "github.com/voocel/ainovel-cli/state" + "github.com/voocel/ainovel-cli/tools" +) + +// UIEvent 是 TUI 消费的结构化事件。 +type UIEvent struct { + Time time.Time + Category string // TOOL / SYSTEM / REVIEW / CHECK / ERROR / AGENT + Summary string + Level string // info / warn / error / success +} + +// UISnapshot 是 TUI 渲染所需的聚合状态快照。 +type UISnapshot struct { + NovelName string + ModelName string + Style string + StatusLabel string // READY / RUNNING / REVIEW / REWRITE / COMPLETE / ERROR + Phase string + Flow string + CurrentChapter int + TotalChapters int + CompletedCount int + TotalWordCount int + InProgressChapter int + CompletedScenes int + PendingRewrites []int + RewriteReason string + PendingSteer string + RecoveryLabel string // 恢复类型描述,空表示新建 + IsRunning bool + + // 详情区 + LastCommitSummary string + LastReviewSummary string + LastCheckpointName string + RecentSummaries []string +} + +// Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。 +type Runtime struct { + cfg Config + store *state.Store + coordinator *agentcore.Agent + events chan UIEvent + done chan struct{} + mu sync.Mutex + running bool + closeOnce sync.Once + doneOnce sync.Once +} + +// Dir 返回当前运行时的输出目录。 +func (rt *Runtime) Dir() string { + return rt.store.Dir() +} + +// NewRuntime 创建 Runtime:初始化 store/model/coordinator,注册事件订阅,但不启动 Prompt。 +func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) (*Runtime, error) { + cfg.FillDefaults() + if err := cfg.ValidateBase(); err != nil { + return nil, err + } + + store := state.NewStore(cfg.OutputDir) + if err := store.Init(); err != nil { + return nil, fmt.Errorf("init store: %w", err) + } + + model, err := createModel(cfg) + if err != nil { + return nil, fmt.Errorf("create model: %w", err) + } + + coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles) + + rt := &Runtime{ + cfg: cfg, + store: store, + coordinator: coordinator, + events: make(chan UIEvent, 100), + done: make(chan struct{}), + } + + // 注册事件订阅:确定性控制 + UIEvent 转发 + registerSubscription(coordinator, store, cfg.MaxChapters, rt.emit) + + // 初始化运行元信息 + if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { + log.Printf("[warn] 初始化运行元信息失败: %v", err) + } + + return rt, nil +} + +// emit 向事件通道发送事件,非阻塞(满时丢弃最旧事件)。 +func (rt *Runtime) emit(ev UIEvent) { + defer func() { recover() }() // 防止 channel 关闭后写入 panic + select { + case rt.events <- ev: + default: + select { + case <-rt.events: + default: + } + select { + case rt.events <- ev: + default: + } + } +} + +// Start 新建模式:初始化进度并启动 coordinator。 +func (rt *Runtime) Start(prompt string) error { + rt.mu.Lock() + if rt.running { + rt.mu.Unlock() + return fmt.Errorf("already running") + } + rt.mu.Unlock() + + if err := rt.store.InitProgress(rt.cfg.NovelName, rt.cfg.MaxChapters); err != nil { + return fmt.Errorf("init progress: %w", err) + } + + promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", rt.cfg.MaxChapters, prompt) + if err := rt.coordinator.Prompt(promptText); err != nil { + return fmt.Errorf("prompt: %w", err) + } + + rt.mu.Lock() + rt.running = true + rt.mu.Unlock() + + go rt.waitDone() + return nil +} + +// Resume 恢复模式:根据 Progress/RunMeta 自动判断恢复类型并启动。 +// 返回恢复标签(空字符串表示无法恢复,应走新建模式)。 +func (rt *Runtime) Resume() (string, error) { + rt.mu.Lock() + if rt.running { + rt.mu.Unlock() + return "", fmt.Errorf("already running") + } + rt.mu.Unlock() + + progress, _ := rt.store.LoadProgress() + runMeta, _ := rt.store.LoadRunMeta() + recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters) + + if recovery.IsNew { + return "", nil + } + + if err := rt.coordinator.Prompt(recovery.PromptText); err != nil { + return "", fmt.Errorf("prompt: %w", err) + } + + rt.mu.Lock() + rt.running = true + rt.mu.Unlock() + + go rt.waitDone() + return recovery.Label, nil +} + +// Steer 提交用户干预。 +func (rt *Runtime) Steer(text string) { + submitSteer(rt.store, rt.coordinator, text) + rt.emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: "干预已提交: " + truncateLog(text, 40), Level: "info"}) +} + +// Snapshot 读取 store 聚合为状态快照。 +func (rt *Runtime) Snapshot() UISnapshot { + snap := UISnapshot{ + NovelName: rt.cfg.NovelName, + ModelName: rt.cfg.ModelName, + Style: rt.cfg.Style, + } + + rt.mu.Lock() + snap.IsRunning = rt.running + rt.mu.Unlock() + + progress, _ := rt.store.LoadProgress() + if progress != nil { + snap.Phase = string(progress.Phase) + snap.Flow = string(progress.Flow) + snap.CurrentChapter = progress.CurrentChapter + snap.TotalChapters = progress.TotalChapters + snap.CompletedCount = len(progress.CompletedChapters) + snap.TotalWordCount = progress.TotalWordCount + snap.InProgressChapter = progress.InProgressChapter + snap.CompletedScenes = len(progress.CompletedScenes) + snap.PendingRewrites = progress.PendingRewrites + snap.RewriteReason = progress.RewriteReason + } + + runMeta, _ := rt.store.LoadRunMeta() + if runMeta != nil { + snap.PendingSteer = runMeta.PendingSteer + } + + // 状态标签映射 + snap.StatusLabel = rt.deriveStatusLabel(progress, snap.IsRunning) + + // 恢复标签 + recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters) + if !recovery.IsNew { + snap.RecoveryLabel = recovery.Label + } + + // 详情区 + rt.fillDetails(&snap, progress) + + return snap +} + +// Events 返回只读事件通道。 +func (rt *Runtime) Events() <-chan UIEvent { + return rt.events +} + +// Done 返回完成信号通道。 +func (rt *Runtime) Done() <-chan struct{} { + return rt.done +} + +// Close 终止 coordinator 并关闭事件通道。 +func (rt *Runtime) Close() { + rt.coordinator.AbortSilent() + finalizeSteerIfIdle(rt.store) + rt.closeOnce.Do(func() { + close(rt.events) + }) +} + +func (rt *Runtime) waitDone() { + rt.coordinator.WaitForIdle() + finalizeSteerIfIdle(rt.store) + rt.mu.Lock() + rt.running = false + rt.mu.Unlock() + rt.doneOnce.Do(func() { + close(rt.done) + }) +} + +func (rt *Runtime) deriveStatusLabel(progress *domain.Progress, isRunning bool) string { + if progress == nil { + return "READY" + } + if progress.Phase == domain.PhaseComplete { + return "COMPLETE" + } + if !isRunning { + return "READY" + } + switch progress.Flow { + case domain.FlowReviewing: + return "REVIEW" + case domain.FlowRewriting, domain.FlowPolishing: + return "REWRITE" + default: + return "RUNNING" + } +} + +func (rt *Runtime) fillDetails(snap *UISnapshot, progress *domain.Progress) { + // 最近 commit:从 progress 的已完成章节 + 摘要推算(信号文件是一次性的,不可靠) + if progress != nil && len(progress.CompletedChapters) > 0 { + lastCh := progress.CompletedChapters[len(progress.CompletedChapters)-1] + wordCount := progress.ChapterWordCounts[lastCh] + snap.LastCommitSummary = fmt.Sprintf("第%d章 %d字", lastCh, wordCount) + } + + // 最近 review + currentCh := 1 + if progress != nil && len(progress.CompletedChapters) > 0 { + currentCh = progress.CompletedChapters[len(progress.CompletedChapters)-1] + } + if review, err := rt.store.LoadLastReview(currentCh); err == nil && review != nil { + snap.LastReviewSummary = fmt.Sprintf("verdict=%s %d个问题", review.Verdict, len(review.Issues)) + if len(review.AffectedChapters) > 0 { + snap.LastReviewSummary += fmt.Sprintf(" 影响%v", review.AffectedChapters) + } + } + + // 最近 checkpoint + snap.LastCheckpointName = rt.latestCheckpoint() + + // 最近两章摘要 + if progress != nil { + for i := len(progress.CompletedChapters) - 1; i >= 0 && len(snap.RecentSummaries) < 2; i-- { + ch := progress.CompletedChapters[i] + if summary, err := rt.store.LoadSummary(ch); err == nil && summary != nil { + snap.RecentSummaries = append(snap.RecentSummaries, + fmt.Sprintf("第%d章: %s", ch, truncateLog(summary.Summary, 50))) + } + } + } +} + +func (rt *Runtime) latestCheckpoint() string { + dir := filepath.Join(rt.store.Dir(), "meta", "checkpoints") + entries, err := os.ReadDir(dir) + if err != nil || len(entries) == 0 { + return "" + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() > entries[j].Name() + }) + name := entries[0].Name() + if ext := filepath.Ext(name); ext != "" { + name = name[:len(name)-len(ext)] + } + return name +} diff --git a/go.mod b/go.mod index f816f59..afbf843 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,34 @@ module github.com/voocel/ainovel-cli go 1.25.5 -require github.com/voocel/agentcore v1.5.1 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/voocel/agentcore v1.5.1 +) -require github.com/voocel/litellm v1.6.0 // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/voocel/litellm v1.6.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum index e4d1f3b..127b41e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,60 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/voocel/agentcore v1.5.1 h1:gEVpBXZfXH4fkq4fLISo2dYfoQ+SaJ0NsetU/Y0hKrI= github.com/voocel/agentcore v1.5.1/go.mod h1:fjksENApgfL1QXbcJY8RUUU5Gl03YOYExFAZ040X/zU= github.com/voocel/litellm v1.6.0 h1:jc0Y7q+cp6QQcag3Mhmd6wMKkfzf7mXjXY0Uvj5VBQw= github.com/voocel/litellm v1.6.0/go.mod h1:6MBUu3I4DHm7h72Vl+3nqLruSwYmgqMf/I9BGoordJ4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go index 5e1ec2b..8b60f89 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "github.com/voocel/ainovel-cli/app" "github.com/voocel/ainovel-cli/tools" + "github.com/voocel/ainovel-cli/tui" ) //go:embed prompts/*.md @@ -20,34 +21,54 @@ var referencesFS embed.FS var stylesFS embed.FS func main() { - prompt := parsePrompt() - if prompt == "" { - fmt.Fprintf(os.Stderr, "用法: novel <小说需求描述>\n") - fmt.Fprintf(os.Stderr, "示例: novel \"写一部3章的都市悬疑短篇,讲述一个程序员在深夜收到神秘代码后卷入一场阴谋\"\n") - os.Exit(1) - } - style := envOr("NOVEL_STYLE", "default") refs := loadReferences(style) prompts := loadPrompts() styles := loadStyles() + cfg := buildConfig(style) + + prompt := parsePrompt() + if prompt != "" { + // CLI 模式:有命令行参数,直接运行 + cfg.Prompt = prompt + if err := app.Run(cfg, refs, prompts, styles); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return + } + + // TUI 模式:无命令行参数,启动交互界面 + if err := tui.Run(cfg, refs, prompts, styles); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func buildConfig(style string) app.Config { + provider := envOr("LLM_PROVIDER", "openai") + apiKey := os.Getenv("OPENAI_API_KEY") + baseURL := os.Getenv("OPENAI_BASE_URL") + if provider == "anthropic" { + apiKey = envOr("ANTHROPIC_API_KEY", apiKey) + baseURL = envOr("ANTHROPIC_BASE_URL", baseURL) + } else if provider == "gemini" { + apiKey = envOr("GEMINI_API_KEY", apiKey) + baseURL = envOr("GEMINI_BASE_URL", baseURL) + } cfg := app.Config{ - Prompt: prompt, NovelName: "novel", - APIKey: os.Getenv("OPENAI_API_KEY"), - BaseURL: os.Getenv("OPENAI_BASE_URL"), + Provider: provider, + APIKey: apiKey, + BaseURL: baseURL, ModelName: envOr("MODEL_NAME", ""), Style: style, } if v := os.Getenv("MAX_CHAPTERS"); v != "" { fmt.Sscanf(v, "%d", &cfg.MaxChapters) } - - if err := app.Run(cfg, refs, prompts, styles); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } + return cfg } func parsePrompt() string { @@ -69,7 +90,6 @@ func loadReferences(style string) tools.References { ContentExpansion: mustRead(referencesFS, "references/content-expansion.md"), DialogueWriting: mustRead(referencesFS, "references/dialogue-writing.md"), } - // 加载风格补充参考(可选) if style != "" && style != "default" { path := "references/" + style + "/style-references.md" if data, err := referencesFS.ReadFile(path); err == nil { diff --git a/tui/app.go b/tui/app.go new file mode 100644 index 0000000..02f4d8e --- /dev/null +++ b/tui/app.go @@ -0,0 +1,50 @@ +package tui + +import ( + "io" + "log" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + "github.com/voocel/ainovel-cli/app" + "github.com/voocel/ainovel-cli/tools" +) + +// Run 启动 TUI 模式。 +func Run(cfg app.Config, refs tools.References, prompts app.Prompts, styles map[string]string) error { + rt, err := app.NewRuntime(cfg, refs, prompts, styles) + if err != nil { + return err + } + restoreLog := redirectLogger(rt.Dir()) + defer restoreLog() + defer rt.Close() + + m := NewModel(rt) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + _, err = p.Run() + return err +} + +// redirectLogger 将标准日志重定向到文件,避免破坏 TUI 画面。 +func redirectLogger(outputDir string) func() { + prev := log.Writer() + logPath := filepath.Join(outputDir, "meta", "tui.log") + if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { + log.SetOutput(io.Discard) + return func() { log.SetOutput(prev) } + } + + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + log.SetOutput(io.Discard) + return func() { log.SetOutput(prev) } + } + + log.SetOutput(f) + return func() { + log.SetOutput(prev) + _ = f.Close() + } +} diff --git a/tui/events.go b/tui/events.go new file mode 100644 index 0000000..84ccbf4 --- /dev/null +++ b/tui/events.go @@ -0,0 +1,82 @@ +package tui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/voocel/ainovel-cli/app" +) + +// 消息类型 +type ( + eventMsg app.UIEvent + snapshotMsg app.UISnapshot + doneMsg struct{} + startResultMsg struct{ err error } + steerResultMsg struct{} + spinnerTickMsg time.Time +) + +// --- Cmd 函数 --- + +func listenEvents(rt *app.Runtime) tea.Cmd { + return func() tea.Msg { + ev, ok := <-rt.Events() + if !ok { + return nil + } + return eventMsg(ev) + } +} + +func listenDone(rt *app.Runtime) tea.Cmd { + return func() tea.Msg { + <-rt.Done() + return doneMsg{} + } +} + +func tickSnapshot(rt *app.Runtime) tea.Cmd { + return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return snapshotMsg(rt.Snapshot()) + }) +} + +func fetchSnapshot(rt *app.Runtime) tea.Cmd { + return func() tea.Msg { + return snapshotMsg(rt.Snapshot()) + } +} + +func checkResume(rt *app.Runtime) tea.Cmd { + return func() tea.Msg { + label, err := rt.Resume() + if err != nil { + return startResultMsg{err: err} + } + if label != "" { + return startResultMsg{err: nil} + } + return nil + } +} + +func startRuntime(rt *app.Runtime, prompt string) tea.Cmd { + return func() tea.Msg { + err := rt.Start(prompt) + return startResultMsg{err: err} + } +} + +func steerRuntime(rt *app.Runtime, text string) tea.Cmd { + return func() tea.Msg { + rt.Steer(text) + return steerResultMsg{} + } +} + +func tickSpinner() tea.Cmd { + return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return spinnerTickMsg(t) + }) +} diff --git a/tui/input.go b/tui/input.go new file mode 100644 index 0000000..bb305cb --- /dev/null +++ b/tui/input.go @@ -0,0 +1,14 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +// renderInputBox 渲染底部输入框区域。 +func renderInputBox(inputView string, width int) string { + style := lipgloss.NewStyle(). + Width(width). + Border(baseBorder, true, false, false, false). + BorderForeground(colorDim). + Padding(0, 1) + + return style.Render(inputView) +} diff --git a/tui/layout.go b/tui/layout.go new file mode 100644 index 0000000..2c65220 --- /dev/null +++ b/tui/layout.go @@ -0,0 +1,53 @@ +package tui + +import "fmt" + +// --- 辅助函数 --- + +func renderField(label, value string) string { + if value == "" { + value = "-" + } + return fieldLabelStyle.Render(label) + fieldValueStyle.Render(value) + "\n" +} + +func renderFlowField(flow string) string { + if flow == "" { + flow = "-" + } + label := fieldLabelStyle.Render("Flow") + if flow != "writing" && flow != "-" && flow != "" { + return label + highlightValueStyle.Render(flow) + "\n" + } + return label + fieldValueStyle.Render(flow) + "\n" +} + +func renderHighlightField(label, value string) string { + return fieldLabelStyle.Render(label) + highlightValueStyle.Render(value) + "\n" +} + +func formatNumber(n int) string { + if n == 0 { + return "0" + } + s := fmt.Sprintf("%d", n) + result := make([]byte, 0, len(s)+len(s)/3) + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + result = append(result, ',') + } + result = append(result, byte(c)) + } + return string(result) +} + +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + if max < 4 { + return string(runes[:max]) + } + return string(runes[:max-3]) + "..." +} diff --git a/tui/model.go b/tui/model.go new file mode 100644 index 0000000..5340497 --- /dev/null +++ b/tui/model.go @@ -0,0 +1,286 @@ +package tui + +import ( + "strings" + "time" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/voocel/ainovel-cli/app" +) + +const maxEvents = 500 + +type appMode int + +const ( + modeNew appMode = iota // 等待用户输入小说需求 + modeRunning // 正在创作 + modeDone // 创作完成 +) + +// spinner 帧序列 +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// Model 是 TUI 的顶层状态。 +type Model struct { + runtime *app.Runtime + snapshot app.UISnapshot + events []app.UIEvent + viewport viewport.Model + textarea textarea.Model + width int + height int + autoScroll bool + mode appMode + err error + spinnerIdx int +} + +// NewModel 创建 TUI Model。 +func NewModel(rt *app.Runtime) Model { + ta := textarea.New() + ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说" + ta.CharLimit = 500 + ta.MaxHeight = 3 + ta.ShowLineNumbers = false + ta.Focus() + + // Enter 不换行(由 Update 处理提交) + ta.KeyMap.InsertNewline.SetEnabled(false) + + vp := viewport.New(80, 20) + vp.SetContent("") + + return Model{ + runtime: rt, + autoScroll: true, + mode: modeNew, + textarea: ta, + viewport: vp, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + textarea.Blink, + listenEvents(m.runtime), + listenDone(m.runtime), + tickSnapshot(m.runtime), + checkResume(m.runtime), + tickSpinner(), + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.textarea.SetWidth(m.width - 4) + m.updateViewportSize() + return m, nil + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC: + return m, tea.Quit + case tea.KeyEscape: + m.textarea.Reset() + return m, nil + case tea.KeyCtrlL: + m.events = nil + m.viewport.SetContent("") + m.viewport.GotoTop() + return m, nil + case tea.KeyEnter: + text := strings.TrimSpace(m.textarea.Value()) + if text == "" { + return m, nil + } + m.textarea.Reset() + switch m.mode { + case modeNew: + m.mode = modeRunning + m.textarea.Placeholder = "输入剧情干预,例如:把感情线提前到第4章" + return m, startRuntime(m.runtime, text) + case modeRunning: + return m, steerRuntime(m.runtime, text) + } + return m, nil + case tea.KeyUp, tea.KeyPgUp: + m.autoScroll = false + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + case tea.KeyDown, tea.KeyPgDown: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + // 滚动到底部时恢复自动跟随 + if m.viewport.AtBottom() { + m.autoScroll = true + } + return m, cmd + case tea.KeyEnd: + m.autoScroll = true + m.viewport.GotoBottom() + return m, nil + } + + case tea.MouseMsg: + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + if msg.Action == tea.MouseActionPress { + m.autoScroll = false + if m.viewport.AtBottom() { + m.autoScroll = true + } + } + return m, cmd + + case eventMsg: + ev := app.UIEvent(msg) + m.events = append(m.events, ev) + if len(m.events) > maxEvents { + m.events = m.events[len(m.events)-maxEvents:] + } + m.refreshEventViewport() + return m, listenEvents(m.runtime) + + case snapshotMsg: + m.snapshot = app.UISnapshot(msg) + return m, tickSnapshot(m.runtime) + + case doneMsg: + m.mode = modeDone + m.textarea.Placeholder = "创作已完成" + m.textarea.Blur() + return m, fetchSnapshot(m.runtime) + + case startResultMsg: + if msg.err != nil { + m.err = msg.err + m.mode = modeNew + m.textarea.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说" + m.events = append(m.events, app.UIEvent{ + Time: time.Now(), Category: "ERROR", Summary: msg.err.Error(), Level: "error", + }) + m.refreshEventViewport() + } else if m.mode == modeNew { + m.mode = modeRunning + m.textarea.Placeholder = "输入剧情干预,例如:把感情线提前到第4章" + } + return m, fetchSnapshot(m.runtime) + + case steerResultMsg: + return m, fetchSnapshot(m.runtime) + + case spinnerTickMsg: + m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames) + return m, tickSpinner() + } + + // 更新 textarea 组件 + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// refreshEventViewport 重新渲染事件流内容并设置 viewport。 +func (m *Model) refreshEventViewport() { + centerW := m.eventFlowWidth() + content := renderEventContent(m.events, centerW) + m.viewport.SetContent(content) + if m.autoScroll { + m.viewport.GotoBottom() + } +} + +// updateViewportSize 根据当前窗口尺寸更新 viewport 大小。 +func (m *Model) updateViewportSize() { + centerW := m.eventFlowWidth() + bodyH := m.bodyHeight() + m.viewport.Width = centerW - 2 + m.viewport.Height = bodyH +} + +func (m *Model) eventFlowWidth() int { + if m.width == 0 { + return 80 + } + leftW := m.width * 25 / 100 + rightW := m.width * 30 / 100 + return m.width - leftW - rightW +} + +func (m *Model) bodyHeight() int { + if m.height == 0 { + return 20 + } + topH := 1 + inputH := 3 + bodyH := m.height - topH - inputH + if bodyH < 3 { + bodyH = 3 + } + return bodyH +} + +func (m Model) View() string { + if m.width == 0 || m.height == 0 { + return "加载中..." + } + if m.width < 100 { + return lipgloss.NewStyle(). + Width(m.width).Height(m.height). + AlignHorizontal(lipgloss.Center). + AlignVertical(lipgloss.Center). + Render("终端宽度不足,请至少扩展到 100 列") + } + + spinnerFrame := "" + if m.snapshot.IsRunning { + spinnerFrame = spinnerFrames[m.spinnerIdx] + } + + topBar := renderTopBar(m.snapshot, m.width, spinnerFrame) + inputBox := renderInputBox(m.textarea.View(), m.width) + + topH := lipgloss.Height(topBar) + inputH := lipgloss.Height(inputBox) + bodyH := m.height - topH - inputH + if bodyH < 3 { + bodyH = 3 + } + + var body string + if m.mode == modeNew && len(m.events) == 0 { + errMsg := "" + if m.err != nil { + errMsg = m.err.Error() + } + body = renderWelcome(m.width, bodyH, errMsg) + } else { + leftW := m.width * 25 / 100 + rightW := m.width * 30 / 100 + centerW := m.width - leftW - rightW + + if m.viewport.Width != centerW-2 || m.viewport.Height != bodyH { + m.viewport.Width = centerW - 2 + m.viewport.Height = bodyH + } + + left := renderStatePanel(m.snapshot, leftW, bodyH) + center := renderEventFlowViewport(m.viewport, centerW, bodyH) + right := renderDetailPanel(m.snapshot, rightW, bodyH) + body = lipgloss.JoinHorizontal(lipgloss.Top, left, center, right) + } + + return lipgloss.JoinVertical(lipgloss.Left, topBar, body, inputBox) +} diff --git a/tui/panels.go b/tui/panels.go new file mode 100644 index 0000000..c34dcf4 --- /dev/null +++ b/tui/panels.go @@ -0,0 +1,187 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + "github.com/charmbracelet/lipgloss" + "github.com/voocel/ainovel-cli/app" +) + +// renderTopBar 渲染顶部状态栏。 +func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string { + left := lipgloss.NewStyle().Foreground(colorText).Bold(true).Render(snap.NovelName) + if snap.Style != "" && snap.Style != "default" { + left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.Style) + } + left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.ModelName) + + // 状态胶囊 + label := snap.StatusLabel + if label == "" { + label = "READY" + } + color, ok := statusColors[label] + if !ok { + color = colorDim + } + capsule := statusCapsule.Foreground(lipgloss.Color("#1a1a2e")).Background(color).Render(label) + + // Spinner(运行中显示) + if snap.IsRunning && spinnerFrame != "" { + capsule = lipgloss.NewStyle().Foreground(colorAccent).Render(spinnerFrame) + " " + capsule + } + + // 左右填充 + gap := width - lipgloss.Width(left) - lipgloss.Width(capsule) - 2 + if gap < 1 { + gap = 1 + } + + return topBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + capsule) +} + +// renderStatePanel 渲染左侧状态面板。 +func renderStatePanel(snap app.UISnapshot, width, height int) string { + var b strings.Builder + + if snap.RecoveryLabel != "" { + b.WriteString(highlightValueStyle.Render("恢复: " + truncate(snap.RecoveryLabel, width-4))) + b.WriteString("\n\n") + } + + b.WriteString(panelTitleStyle.Render("状态")) + b.WriteString("\n") + b.WriteString(renderField("Phase", snap.Phase)) + b.WriteString(renderFlowField(snap.Flow)) + b.WriteString(renderField("Chapter", fmt.Sprintf("%d / %d", snap.CompletedCount, snap.TotalChapters))) + b.WriteString(renderField("Words", formatNumber(snap.TotalWordCount))) + + if snap.InProgressChapter > 0 { + b.WriteString(renderField("Writing", fmt.Sprintf("第%d章 场景%d", snap.InProgressChapter, snap.CompletedScenes))) + } + + if len(snap.PendingRewrites) > 0 { + b.WriteString("\n") + b.WriteString(panelTitleStyle.Render("返工")) + b.WriteString("\n") + b.WriteString(renderHighlightField("Pending", fmt.Sprintf("%v", snap.PendingRewrites))) + if snap.RewriteReason != "" { + b.WriteString(renderField("Reason", truncate(snap.RewriteReason, width-12))) + } + } + + if snap.PendingSteer != "" { + b.WriteString("\n") + b.WriteString(panelTitleStyle.Render("干预")) + b.WriteString("\n") + b.WriteString(renderHighlightField("Steer", truncate(snap.PendingSteer, width-12))) + } + + style := lipgloss.NewStyle(). + Width(width). + Height(height). + Border(baseBorder, false, true, false, false). + BorderForeground(colorDim). + Padding(0, 1) + + return style.Render(b.String()) +} + +// renderEventContent 将事件列表渲染为纯文本(供 viewport 使用)。 +func renderEventContent(events []app.UIEvent, width int) string { + var b strings.Builder + for i, ev := range events { + ts := ev.Time.Format("15:04:05") + cat := ev.Category + + color, ok := categoryColors[cat] + if !ok { + color = colorText + } + + catStyle := lipgloss.NewStyle().Foreground(color).Width(7) + tsStyle := lipgloss.NewStyle().Foreground(colorDim) + sumStyle := lipgloss.NewStyle().Foreground(color) + + line := tsStyle.Render(ts) + " " + catStyle.Render(cat) + " " + sumStyle.Render(truncate(ev.Summary, width-20)) + b.WriteString(line) + if i < len(events)-1 { + b.WriteString("\n") + } + } + return b.String() +} + +// renderEventFlowViewport 用 viewport 包装渲染事件流面板。 +func renderEventFlowViewport(vp viewport.Model, width, height int) string { + style := lipgloss.NewStyle(). + Width(width). + Height(height). + Padding(0, 1) + + return style.Render(vp.View()) +} + +// renderDetailPanel 渲染右侧详情面板。 +func renderDetailPanel(snap app.UISnapshot, width, height int) string { + var b strings.Builder + + if snap.LastCommitSummary != "" { + b.WriteString(cardTitleStyle.Render("─ 最近提交 ─")) + b.WriteString("\n") + b.WriteString(cardContentStyle.Render(snap.LastCommitSummary)) + b.WriteString("\n\n") + } + + if snap.LastReviewSummary != "" { + b.WriteString(cardTitleStyle.Render("─ 最近审阅 ─")) + b.WriteString("\n") + b.WriteString(cardContentStyle.Render(snap.LastReviewSummary)) + b.WriteString("\n\n") + } + + if snap.LastCheckpointName != "" { + b.WriteString(cardTitleStyle.Render("─ 检查点 ─")) + b.WriteString("\n") + b.WriteString(cardContentStyle.Render(snap.LastCheckpointName)) + b.WriteString("\n\n") + } + + if len(snap.RecentSummaries) > 0 { + b.WriteString(cardTitleStyle.Render("─ 摘要 ─")) + b.WriteString("\n") + for _, s := range snap.RecentSummaries { + b.WriteString(cardContentStyle.Render(s)) + b.WriteString("\n") + } + } + + style := lipgloss.NewStyle(). + Width(width). + Height(height). + Border(baseBorder, false, false, false, true). + BorderForeground(colorDim). + Padding(0, 1) + + return style.Render(b.String()) +} + +// renderWelcome 渲染新建态首屏。 +func renderWelcome(width, height int, errMsg string) string { + content := lipgloss.NewStyle().Foreground(colorText).Render("还没有开始创作。") + "\n\n" + + lipgloss.NewStyle().Foreground(colorDim).Render("请输入你的小说需求,系统会先进入设定与大纲阶段。") + "\n\n" + + lipgloss.NewStyle().Foreground(colorAccent).Render("示例:写一部 12 章都市悬疑小说,主角是一名女法医") + + if errMsg != "" { + content += "\n\n" + lipgloss.NewStyle().Foreground(colorError).Bold(true).Render("错误: "+errMsg) + } + + return lipgloss.NewStyle(). + Width(width). + Height(height). + AlignHorizontal(lipgloss.Center). + AlignVertical(lipgloss.Center). + Render(content) +} diff --git a/tui/theme.go b/tui/theme.go new file mode 100644 index 0000000..fd63425 --- /dev/null +++ b/tui/theme.go @@ -0,0 +1,68 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +// 主题色板 +var ( + colorText = lipgloss.Color("#e0d8c8") + colorDim = lipgloss.Color("#666666") + colorAccent = lipgloss.Color("#d4a017") // 琥珀黄 + colorSuccess = lipgloss.Color("#2ecc71") // 冷绿 + colorError = lipgloss.Color("#e74c3c") // 朱红 + colorReview = lipgloss.Color("#e67e22") // 橙色 +) + +// 状态标签颜色映射 +var statusColors = map[string]lipgloss.Color{ + "READY": colorDim, + "RUNNING": colorSuccess, + "REVIEW": colorReview, + "REWRITE": colorReview, + "COMPLETE": colorSuccess, + "ERROR": colorError, +} + +// 事件分类颜色映射 +var categoryColors = map[string]lipgloss.Color{ + "TOOL": colorText, + "SYSTEM": colorAccent, + "REVIEW": colorReview, + "CHECK": colorSuccess, + "ERROR": colorError, + "AGENT": colorDim, +} + +// 基础样式 +var ( + baseBorder = lipgloss.RoundedBorder() + + topBarStyle = lipgloss.NewStyle(). + Foreground(colorText). + Padding(0, 1) + + statusCapsule = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true) + + panelTitleStyle = lipgloss.NewStyle(). + Foreground(colorAccent). + Bold(true) + + fieldLabelStyle = lipgloss.NewStyle(). + Foreground(colorDim). + Width(10) + + fieldValueStyle = lipgloss.NewStyle(). + Foreground(colorText) + + highlightValueStyle = lipgloss.NewStyle(). + Foreground(colorAccent). + Bold(true) + + cardTitleStyle = lipgloss.NewStyle(). + Foreground(colorDim). + Italic(true) + + cardContentStyle = lipgloss.NewStyle(). + Foreground(colorText) +)