feat: support tui

This commit is contained in:
voocel
2026-03-08 12:02:46 +08:00
parent 2445560465
commit 75bdda1fe3
13 changed files with 1435 additions and 152 deletions

View File

@@ -16,7 +16,11 @@ import (
"github.com/voocel/ainovel-cli/tools"
)
// Run 启动小说创作流程
// emitFn 是可选的 UIEvent 发射回调,用于向 TUI 转发结构化事件
// CLI 模式下为 nilRuntime 模式下指向 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...)
}
}