feat: support tui
This commit is contained in:
@@ -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"
|
||||
|
||||
347
app/run.go
347
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...)
|
||||
}
|
||||
}
|
||||
|
||||
333
app/runtime.go
Normal file
333
app/runtime.go
Normal file
@@ -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
|
||||
}
|
||||
32
go.mod
32
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
|
||||
)
|
||||
|
||||
56
go.sum
56
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=
|
||||
|
||||
52
main.go
52
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 {
|
||||
|
||||
50
tui/app.go
Normal file
50
tui/app.go
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
82
tui/events.go
Normal file
82
tui/events.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
14
tui/input.go
Normal file
14
tui/input.go
Normal file
@@ -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)
|
||||
}
|
||||
53
tui/layout.go
Normal file
53
tui/layout.go
Normal file
@@ -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]) + "..."
|
||||
}
|
||||
286
tui/model.go
Normal file
286
tui/model.go
Normal file
@@ -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)
|
||||
}
|
||||
187
tui/panels.go
Normal file
187
tui/panels.go
Normal file
@@ -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)
|
||||
}
|
||||
68
tui/theme.go
Normal file
68
tui/theme.go
Normal file
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user