diff --git a/app/agents.go b/app/agents.go index f5383da..44b3a75 100644 --- a/app/agents.go +++ b/app/agents.go @@ -7,6 +7,7 @@ import ( ) // BuildCoordinator 组装 Coordinator Agent 及其 SubAgent。 +// 返回 Agent 和 AskUserTool(供调用方注入 handler)。 func BuildCoordinator( cfg Config, store *state.Store, @@ -14,9 +15,10 @@ func BuildCoordinator( refs tools.References, prompts Prompts, styles map[string]string, -) *agentcore.Agent { +) (*agentcore.Agent, *tools.AskUserTool) { // 共享工具 contextTool := tools.NewContextTool(store, refs, cfg.Style) + askUser := tools.NewAskUserTool() // Architect SubAgent 工具 architectTools := []agentcore.Tool{ @@ -75,10 +77,11 @@ func BuildCoordinator( subagentTool := agentcore.NewSubAgentTool(architect, writer, editor) - return agentcore.NewAgent( + agent := agentcore.NewAgent( agentcore.WithModel(model), agentcore.WithSystemPrompt(prompts.Coordinator), - agentcore.WithTools(subagentTool, contextTool), + agentcore.WithTools(subagentTool, contextTool, askUser), agentcore.WithMaxTurns(60), ) + return agent, askUser } diff --git a/app/config.go b/app/config.go index 93d14d2..a91fa7c 100644 --- a/app/config.go +++ b/app/config.go @@ -14,8 +14,7 @@ type Config struct { ModelName string // LLM 模型名 APIKey string // API Key BaseURL string // API Base URL(可选) - MaxChapters int // 最大章节数 - Style string // 写作风格(default/suspense/fantasy/romance) + Style string // 写作风格(default/suspense/fantasy/romance) } // Prompts 嵌入的提示词。 @@ -71,7 +70,4 @@ func (c *Config) FillDefaults() { if c.Style == "" { c.Style = "default" } - if c.MaxChapters <= 0 { - c.MaxChapters = 3 - } } diff --git a/app/run.go b/app/run.go index 488d5b4..a9ecdd4 100644 --- a/app/run.go +++ b/app/run.go @@ -2,6 +2,8 @@ package app import ( "bufio" + "context" + "encoding/json" "fmt" "log" "os" @@ -9,6 +11,8 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/voocel/agentcore" "github.com/voocel/agentcore/llm" "github.com/voocel/ainovel-cli/domain" @@ -20,6 +24,12 @@ import ( // CLI 模式下为 nil,Runtime 模式下指向 events channel。 type emitFn func(UIEvent) +// deltaFn 是可选的流式 token 回调,用于向 TUI 转发 LLM 生成的文字。 +type deltaFn func(delta string) + +// clearFn 是可选的流式缓冲清空回调,在新一轮 LLM 输出开始时触发。 +type clearFn func() + // Run 启动小说创作流程(CLI 模式,阻塞直到完成)。 func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) error { cfg.FillDefaults() @@ -40,10 +50,11 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s } // 3. 组装 Coordinator - coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles) + coordinator, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles) + askUser.SetHandler(cliAskUserHandler) // 4. 确定性控制面:事件监听 + FollowUp 注入 - registerSubscription(coordinator, store, cfg.MaxChapters, nil) + registerSubscription(coordinator, store, nil, nil, nil) // 5. 初始化运行元信息(保留已有 SteerHistory) if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { @@ -65,14 +76,14 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s // 7. 恢复或启动 progress, _ := store.LoadProgress() runMeta, _ := store.LoadRunMeta() - recovery := determineRecovery(progress, runMeta, cfg.MaxChapters) + recovery := determineRecovery(progress, runMeta) if recovery.IsNew { - if err := store.InitProgress(cfg.NovelName, cfg.MaxChapters); err != nil { + if err := store.InitProgress(cfg.NovelName, 0); err != nil { return fmt.Errorf("init progress: %w", err) } - log.Printf("新建模式:%s(%d 章)", cfg.NovelName, cfg.MaxChapters) - promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", cfg.MaxChapters, cfg.Prompt) + log.Printf("新建模式:%s", cfg.NovelName) + promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", cfg.Prompt) if err := coordinator.Prompt(promptText); err != nil { return fmt.Errorf("prompt: %w", err) } @@ -96,8 +107,8 @@ 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) { +// registerSubscription 注册 coordinator 事件订阅,包含确定性控制和可选的 UIEvent/Delta 转发。 +func registerSubscription(coordinator *agentcore.Agent, store *state.Store, emit emitFn, onDelta deltaFn, onClear clearFn) { coordinator.Subscribe(func(ev agentcore.Event) { switch ev.Type { case agentcore.EventToolExecStart: @@ -106,6 +117,32 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxC emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: ev.Tool + ".start", Level: "info"}) } + case agentcore.EventToolExecUpdate: + // 区分流式 delta 和进度摘要 + if delta, ok := parseStreamDelta(ev); ok { + if onDelta != nil { + onDelta(delta) + } + return + } + summary := parseProgressSummary(ev) + log.Printf("[progress] %s", summary) + if emit != nil { + emit(UIEvent{Time: time.Now(), Category: "TOOL", Summary: summary, Level: "info"}) + } + + case agentcore.EventMessageStart: + // 新一轮 LLM 输出开始,清空流式缓冲 + if onClear != nil { + onClear() + } + + case agentcore.EventMessageUpdate: + // Coordinator 自身思考时的流式 token + if ev.Delta != "" && onDelta != nil { + onDelta(ev.Delta) + } + case agentcore.EventToolExecEnd: if ev.IsError { log.Printf("[tool:error] %s", ev.Tool) @@ -120,7 +157,7 @@ func registerSubscription(coordinator *agentcore.Agent, store *state.Store, maxC } if ev.Tool == "subagent" { - handleSubAgentDone(coordinator, store, maxChapters, emit) + handleSubAgentDone(coordinator, store, emit) handleEditorDone(coordinator, store, emit) } @@ -169,7 +206,8 @@ type recoveryResult struct { } // determineRecovery 根据 Progress 和 RunMeta 判断恢复类型和 Prompt 文本。 -func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta, maxChapters int) recoveryResult { +// 章节总数完全来自 Progress.TotalChapters(由大纲自动设定),不再由外部传入。 +func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta) recoveryResult { if progress == nil { return recoveryResult{IsNew: true} } @@ -232,7 +270,7 @@ func determineRecovery(progress *domain.Progress, runMeta *domain.RunMeta, maxCh } // handleSubAgentDone 在每次 SubAgent 调用完成后读取文件系统信号,注入确定性任务。 -func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxChapters int, emit emitFn) { +func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, emit emitFn) { result, err := store.LoadLastCommit() if err != nil || result == nil { return @@ -281,20 +319,24 @@ func handleSubAgentDone(coordinator *agentcore.Agent, store *state.Store, maxCha return } - // 确定性判断 1:全书完成 - if result.NextChapter > maxChapters { - log.Printf("[host] 所有 %d 章已完成,注入完成指令", maxChapters) + // 确定性判断 1:全书完成(TotalChapters 由大纲自动设定) + totalChapters := 0 + if progress != nil { + totalChapters = progress.TotalChapters + } + if totalChapters > 0 && result.NextChapter > totalChapters { + log.Printf("[host] 所有 %d 章已完成,注入完成指令", totalChapters) if err := store.MarkComplete(); err != nil { log.Printf("[host] 标记完成失败: %v", err) } clearHandledSteer(store) 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"}) + emit(UIEvent{Time: time.Now(), Category: "SYSTEM", Summary: fmt.Sprintf("全部 %d 章已完成", totalChapters), Level: "success"}) } coordinator.FollowUp(agentcore.UserMsg(fmt.Sprintf( "[系统] 全部 %d 章已写完。请总结全书并结束。不要再调用 writer。", - maxChapters))) + totalChapters))) return } @@ -387,6 +429,50 @@ func saveCheckpoint(store *state.Store, label string) { } } +// parseStreamDelta 从 EventToolExecUpdate 中提取流式 delta 文本。 +// 如果事件是 SubAgent 转发的 token delta(含 "delta" 字段),返回文本和 true。 +func parseStreamDelta(ev agentcore.Event) (string, bool) { + if len(ev.Result) == 0 { + return "", false + } + var data struct { + Delta string `json:"delta"` + } + if err := json.Unmarshal(ev.Result, &data); err != nil { + return "", false + } + if data.Delta != "" { + return data.Delta, true + } + return "", false +} + +// parseProgressSummary 从 EventToolExecUpdate 中提取可读摘要。 +func parseProgressSummary(ev agentcore.Event) string { + if len(ev.Result) == 0 { + return "progress" + } + var data struct { + Agent string `json:"agent"` + Tool string `json:"tool"` + Turn int `json:"turn"` + Error bool `json:"error"` + } + if err := json.Unmarshal(ev.Result, &data); err != nil { + return truncateLog(string(ev.Result), 60) + } + if data.Tool != "" { + if data.Error { + return fmt.Sprintf("%s → %s (error)", data.Agent, data.Tool) + } + return fmt.Sprintf("%s → %s", data.Agent, data.Tool) + } + if data.Turn > 0 { + return fmt.Sprintf("%s turn %d", data.Agent, data.Turn) + } + return truncateLog(string(ev.Result), 60) +} + func truncateLog(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { @@ -434,3 +520,159 @@ func createModel(cfg Config) (agentcore.ChatModel, error) { return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) } } + +// cliAskUserHandler 是 CLI 模式下的交互式选择器,上下键选择,回车确认。 +func cliAskUserHandler(_ context.Context, questions []tools.Question) (*tools.AskUserResponse, error) { + resp := &tools.AskUserResponse{ + Answers: make(map[string]string), + Notes: make(map[string]string), + } + for _, q := range questions { + m := newSelectModel(q) + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + final, err := p.Run() + if err != nil { + return resp, err + } + result := final.(selectModel) + if result.cancelled { + continue + } + resp.Answers[q.Question] = result.answer + if result.isCustom { + resp.Notes[q.Question] = result.answer + } + } + return resp, nil +} + +// ---------- 交互式选择器(bubbletea mini program)---------- + +var ( + selectCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) + selectDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + selectHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")) + selectInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) +) + +type selectModel struct { + question tools.Question + items []string // label 列表,最后一项是"自由输入" + descs []string // 描述列表 + cursor int + answer string + isCustom bool + cancelled bool + typing bool // 是否进入自由输入模式 + input string // 自由输入缓冲 +} + +func newSelectModel(q tools.Question) selectModel { + items := make([]string, 0, len(q.Options)+1) + descs := make([]string, 0, len(q.Options)+1) + for _, opt := range q.Options { + items = append(items, opt.Label) + descs = append(descs, opt.Description) + } + items = append(items, "自由输入") + descs = append(descs, "以上都不合适,我自己写") + return selectModel{question: q, items: items, descs: descs} +} + +func (m selectModel) Init() tea.Cmd { return nil } + +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.typing { + return m.updateTyping(msg) + } + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "enter": + if m.cursor == len(m.items)-1 { + m.typing = true + return m, nil + } + m.answer = m.items[m.cursor] + return m, tea.Quit + case "q", "esc", "ctrl+c": + m.cancelled = true + return m, tea.Quit + } + } + return m, nil +} + +func (m selectModel) updateTyping(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + text := strings.TrimSpace(m.input) + if text == "" { + return m, nil + } + m.answer = text + m.isCustom = true + return m, tea.Quit + case "esc": + m.typing = false + m.input = "" + return m, nil + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + case "backspace": + if len(m.input) > 0 { + runes := []rune(m.input) + m.input = string(runes[:len(runes)-1]) + } + default: + if msg.Type == tea.KeyRunes { + m.input += string(msg.Runes) + } else if msg.Type == tea.KeySpace { + m.input += " " + } + } + } + return m, nil +} + +func (m selectModel) View() string { + var b strings.Builder + b.WriteString(selectHeaderStyle.Render(fmt.Sprintf("[%s] %s", m.question.Header, m.question.Question))) + b.WriteString("\n\n") + + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = selectCursorStyle.Render("❯ ") + } + label := item + if i == m.cursor { + label = selectCursorStyle.Render(item) + } + desc := selectDescStyle.Render(" " + m.descs[i]) + b.WriteString(fmt.Sprintf("%s%s%s\n", cursor, label, desc)) + } + + if m.typing { + b.WriteString("\n") + b.WriteString(selectInputStyle.Render(" ✎ ")) + b.WriteString(m.input) + b.WriteString(selectCursorStyle.Render("▌")) + b.WriteString(selectDescStyle.Render(" (Enter 确认, Esc 返回)")) + } else { + b.WriteString(selectDescStyle.Render("\n ↑↓ 选择 Enter 确认 Esc 取消")) + } + + return b.String() +} diff --git a/app/runtime.go b/app/runtime.go index 52e16a1..d9487e2 100644 --- a/app/runtime.go +++ b/app/runtime.go @@ -43,6 +43,11 @@ type UISnapshot struct { RecoveryLabel string // 恢复类型描述,空表示新建 IsRunning bool + // 基础设定 + Premise string // 前提概要 + Outline []OutlineSnapshot // 大纲(每章标题 + 核心事件) + Characters []string // 角色列表(名字 + 身份) + // 详情区 LastCommitSummary string LastReviewSummary string @@ -50,12 +55,22 @@ type UISnapshot struct { RecentSummaries []string } +// OutlineSnapshot 是大纲条目的展示摘要。 +type OutlineSnapshot struct { + Chapter int + Title string + CoreEvent string +} + // Runtime 封装协调器生命周期,提供 TUI 所需的非阻塞接口。 type Runtime struct { cfg Config store *state.Store coordinator *agentcore.Agent + askUser *tools.AskUserTool events chan UIEvent + streamCh chan string // 流式 token channel(独立于 events,避免淹没事件日志) + clearCh chan struct{} // 流式缓冲清空信号 done chan struct{} mu sync.Mutex running bool @@ -68,6 +83,11 @@ func (rt *Runtime) Dir() string { return rt.store.Dir() } +// AskUser 返回 ask_user 工具实例,供 TUI 注入交互 handler。 +func (rt *Runtime) AskUser() *tools.AskUserTool { + return rt.askUser +} + // NewRuntime 创建 Runtime:初始化 store/model/coordinator,注册事件订阅,但不启动 Prompt。 func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[string]string) (*Runtime, error) { cfg.FillDefaults() @@ -85,18 +105,21 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s return nil, fmt.Errorf("create model: %w", err) } - coordinator := BuildCoordinator(cfg, store, model, refs, prompts, styles) + coordinator, askUser := BuildCoordinator(cfg, store, model, refs, prompts, styles) rt := &Runtime{ cfg: cfg, store: store, coordinator: coordinator, + askUser: askUser, events: make(chan UIEvent, 100), + streamCh: make(chan string, 256), + clearCh: make(chan struct{}, 4), done: make(chan struct{}), } - // 注册事件订阅:确定性控制 + UIEvent 转发 - registerSubscription(coordinator, store, cfg.MaxChapters, rt.emit) + // 注册事件订阅:确定性控制 + UIEvent 转发 + 流式 delta 转发 + registerSubscription(coordinator, store, rt.emit, rt.emitDelta, rt.emitClear) // 初始化运行元信息 if err := store.InitRunMeta(cfg.Style, cfg.ModelName); err != nil { @@ -106,6 +129,43 @@ func NewRuntime(cfg Config, refs tools.References, prompts Prompts, styles map[s return rt, nil } +// Stream 返回只读流式 token 通道。 +func (rt *Runtime) Stream() <-chan string { + return rt.streamCh +} + +// StreamClear 返回只读流式清空信号通道。 +func (rt *Runtime) StreamClear() <-chan struct{} { + return rt.clearCh +} + +// emitClear 发送流式缓冲清空信号,非阻塞。 +func (rt *Runtime) emitClear() { + defer func() { recover() }() + select { + case rt.clearCh <- struct{}{}: + default: + } +} + +// emitDelta 向流式通道发送 token,非阻塞(满时丢弃旧数据)。 +func (rt *Runtime) emitDelta(delta string) { + defer func() { recover() }() + select { + case rt.streamCh <- delta: + default: + // 满了就丢弃最旧的再写入 + select { + case <-rt.streamCh: + default: + } + select { + case rt.streamCh <- delta: + default: + } + } +} + // emit 向事件通道发送事件,非阻塞(满时丢弃最旧事件)。 func (rt *Runtime) emit(ev UIEvent) { defer func() { recover() }() // 防止 channel 关闭后写入 panic @@ -132,11 +192,11 @@ func (rt *Runtime) Start(prompt string) error { } rt.mu.Unlock() - if err := rt.store.InitProgress(rt.cfg.NovelName, rt.cfg.MaxChapters); err != nil { + if err := rt.store.InitProgress(rt.cfg.NovelName, 0); err != nil { return fmt.Errorf("init progress: %w", err) } - promptText := fmt.Sprintf("请创作一部 %d 章的小说。要求如下:\n\n%s", rt.cfg.MaxChapters, prompt) + promptText := fmt.Sprintf("请创作一部小说,章节数量由你根据故事需要自行决定。要求如下:\n\n%s", prompt) if err := rt.coordinator.Prompt(promptText); err != nil { return fmt.Errorf("prompt: %w", err) } @@ -161,7 +221,7 @@ func (rt *Runtime) Resume() (string, error) { progress, _ := rt.store.LoadProgress() runMeta, _ := rt.store.LoadRunMeta() - recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters) + recovery := determineRecovery(progress, runMeta) if recovery.IsNew { return "", nil @@ -220,7 +280,7 @@ func (rt *Runtime) Snapshot() UISnapshot { snap.StatusLabel = rt.deriveStatusLabel(progress, snap.IsRunning) // 恢复标签 - recovery := determineRecovery(progress, runMeta, rt.cfg.MaxChapters) + recovery := determineRecovery(progress, runMeta) if !recovery.IsNew { snap.RecoveryLabel = recovery.Label } @@ -247,6 +307,8 @@ func (rt *Runtime) Close() { finalizeSteerIfIdle(rt.store) rt.closeOnce.Do(func() { close(rt.events) + close(rt.streamCh) + close(rt.clearCh) }) } @@ -282,6 +344,27 @@ func (rt *Runtime) deriveStatusLabel(progress *domain.Progress, isRunning bool) } func (rt *Runtime) fillDetails(snap *UISnapshot, progress *domain.Progress) { + // 基础设定 + if premise, _ := rt.store.LoadPremise(); premise != "" { + snap.Premise = truncateLog(premise, 80) + } + if outline, _ := rt.store.LoadOutline(); len(outline) > 0 { + for _, e := range outline { + snap.Outline = append(snap.Outline, OutlineSnapshot{ + Chapter: e.Chapter, Title: e.Title, CoreEvent: e.CoreEvent, + }) + } + } + if chars, _ := rt.store.LoadCharacters(); len(chars) > 0 { + for _, c := range chars { + label := c.Name + if c.Role != "" { + label += "(" + c.Role + ")" + } + snap.Characters = append(snap.Characters, label) + } + } + // 最近 commit:从 progress 的已完成章节 + 摘要推算(信号文件是一次性的,不可靠) if progress != nil && len(progress.CompletedChapters) > 0 { lastCh := progress.CompletedChapters[len(progress.CompletedChapters)-1] diff --git a/main.go b/main.go index 8b60f89..912c96b 100644 --- a/main.go +++ b/main.go @@ -65,9 +65,6 @@ func buildConfig(style string) app.Config { ModelName: envOr("MODEL_NAME", ""), Style: style, } - if v := os.Getenv("MAX_CHAPTERS"); v != "" { - fmt.Sscanf(v, "%d", &cfg.MaxChapters) - } return cfg } diff --git a/state/progress.go b/state/progress.go index 000aded..53775bf 100644 --- a/state/progress.go +++ b/state/progress.go @@ -34,6 +34,19 @@ func (s *Store) InitProgress(novelName string, totalChapters int) error { }) } +// SetTotalChapters 根据大纲长度设定总章节数。 +func (s *Store) SetTotalChapters(n int) error { + p, err := s.LoadProgress() + if err != nil { + return err + } + if p == nil { + p = &domain.Progress{} + } + p.TotalChapters = n + return s.SaveProgress(p) +} + // UpdatePhase 更新创作阶段。 func (s *Store) UpdatePhase(phase domain.Phase) error { p, err := s.LoadProgress() diff --git a/tools/ask_user.go b/tools/ask_user.go new file mode 100644 index 0000000..11ec97a --- /dev/null +++ b/tools/ask_user.go @@ -0,0 +1,154 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "unicode/utf8" + + "github.com/voocel/agentcore/schema" +) + +// AskUserResponse 用户回答结果。 +type AskUserResponse struct { + Answers map[string]string // question text → 用户选择的答案 + Notes map[string]string // question text → 自定义输入(选"其他"时) +} + +// AskUserHandler 阻塞等待用户回答,由 CLI 或 TUI 注入具体实现。 +type AskUserHandler func(ctx context.Context, questions []Question) (*AskUserResponse, error) + +// Question 单个问题。 +type Question struct { + Question string `json:"question"` + Header string `json:"header"` + Options []Option `json:"options"` + MultiSelect bool `json:"multiSelect"` +} + +// Option 可选项。 +type Option struct { + Label string `json:"label"` + Description string `json:"description"` +} + +// AskUserTool 让 LLM 向用户提出结构化问题。 +type AskUserTool struct { + mu sync.RWMutex + handler AskUserHandler +} + +func NewAskUserTool() *AskUserTool { + return &AskUserTool{} +} + +// SetHandler 注入 UI 回调,CLI 和 TUI 各自实现。 +func (t *AskUserTool) SetHandler(h AskUserHandler) { + t.mu.Lock() + t.handler = h + t.mu.Unlock() +} + +func (t *AskUserTool) Name() string { return "ask_user" } +func (t *AskUserTool) Label() string { return "询问用户" } +func (t *AskUserTool) Description() string { + return "向用户提出结构化问题,用于需要用户确认方向、澄清需求或做出选择时。用户可以从预设选项中选择,也可以自由输入。" +} + +func (t *AskUserTool) Schema() map[string]any { + option := schema.Object( + schema.Property("label", schema.String("选项显示文本(1-5个词)")).Required(), + schema.Property("description", schema.String("选项含义说明")).Required(), + ) + question := schema.Object( + schema.Property("question", schema.String("完整的问题文本")).Required(), + schema.Property("header", schema.String("短标签(最多12字符)")).Required(), + schema.Property("options", schema.Array("2-4个可选项", option)).Required(), + schema.Property("multiSelect", schema.Bool("是否允许多选")), + ) + return schema.Object( + schema.Property("questions", schema.Array("1-4个问题", question)).Required(), + ) +} + +type askUserArgs struct { + Questions []Question `json:"questions"` +} + +func (t *AskUserTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a askUserArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("invalid args: %w", err) + } + if err := validateQuestions(a.Questions); err != nil { + return json.Marshal(fmt.Sprintf("参数校验失败: %s", err)) + } + + t.mu.RLock() + h := t.handler + t.mu.RUnlock() + + if h == nil { + return json.Marshal("当前环境不支持交互式询问,请根据你的判断自行决策并继续。") + } + + resp, err := h(ctx, a.Questions) + if err != nil { + return json.Marshal(fmt.Sprintf("用户交互失败: %s。请根据你的判断自行决策并继续。", err)) + } + + return json.Marshal(formatAnswers(a.Questions, resp)) +} + +func validateQuestions(questions []Question) error { + if len(questions) == 0 { + return fmt.Errorf("至少需要一个问题") + } + if len(questions) > 4 { + return fmt.Errorf("最多4个问题,当前 %d 个", len(questions)) + } + for i, q := range questions { + if q.Question == "" { + return fmt.Errorf("问题 %d: 问题文本不能为空", i+1) + } + if q.Header == "" { + return fmt.Errorf("问题 %d: header 不能为空", i+1) + } + if utf8.RuneCountInString(q.Header) > 12 { + return fmt.Errorf("问题 %d: header %q 超过12字符", i+1, q.Header) + } + if len(q.Options) < 2 || len(q.Options) > 4 { + return fmt.Errorf("问题 %d: 需要2-4个选项,当前 %d 个", i+1, len(q.Options)) + } + for j, opt := range q.Options { + if opt.Label == "" { + return fmt.Errorf("问题 %d 选项 %d: label 不能为空", i+1, j+1) + } + if opt.Description == "" { + return fmt.Errorf("问题 %d 选项 %d: description 不能为空", i+1, j+1) + } + } + } + return nil +} + +func formatAnswers(questions []Question, resp *AskUserResponse) string { + if resp == nil || len(resp.Answers) == 0 { + return "用户未提供回答,请根据你的判断自行决策并继续。" + } + var parts []string + for _, q := range questions { + answer, ok := resp.Answers[q.Question] + if !ok { + continue + } + entry := fmt.Sprintf("[%s] %s", q.Header, answer) + if note, hasNote := resp.Notes[q.Question]; hasNote { + entry += "(补充:" + note + ")" + } + parts = append(parts, entry) + } + return fmt.Sprintf("用户回答:%s", strings.Join(parts, ";")) +} diff --git a/tools/context.go b/tools/novel_context.go similarity index 100% rename from tools/context.go rename to tools/novel_context.go diff --git a/tools/save_foundation.go b/tools/save_foundation.go index d7ba3ea..cce451c 100644 --- a/tools/save_foundation.go +++ b/tools/save_foundation.go @@ -58,6 +58,8 @@ func (t *SaveFoundationTool) Execute(_ context.Context, args json.RawMessage) (j return nil, fmt.Errorf("save outline: %w", err) } _ = t.store.UpdatePhase(domain.PhaseOutline) + // 根据大纲长度自动设定总章节数 + _ = t.store.SetTotalChapters(len(entries)) return json.Marshal(map[string]any{"saved": true, "type": "outline", "chapters": len(entries)}) case "characters": diff --git a/tui/events.go b/tui/events.go index 84ccbf4..328b4db 100644 --- a/tui/events.go +++ b/tui/events.go @@ -9,12 +9,14 @@ import ( // 消息类型 type ( - eventMsg app.UIEvent - snapshotMsg app.UISnapshot - doneMsg struct{} + eventMsg app.UIEvent + snapshotMsg app.UISnapshot + doneMsg struct{} startResultMsg struct{ err error } steerResultMsg struct{} spinnerTickMsg time.Time + streamDeltaMsg string // 流式 token 增量 + streamClearMsg struct{} // 清空流式缓冲(新消息开始) ) // --- Cmd 函数 --- @@ -76,7 +78,27 @@ func steerRuntime(rt *app.Runtime, text string) tea.Cmd { } func tickSpinner() tea.Cmd { - return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg { + return tea.Tick(350*time.Millisecond, func(t time.Time) tea.Msg { return spinnerTickMsg(t) }) } + +func listenStream(rt *app.Runtime) tea.Cmd { + return func() tea.Msg { + delta, ok := <-rt.Stream() + if !ok { + return nil + } + return streamDeltaMsg(delta) + } +} + +func listenStreamClear(rt *app.Runtime) tea.Cmd { + return func() tea.Msg { + _, ok := <-rt.StreamClear() + if !ok { + return nil + } + return streamClearMsg{} + } +} diff --git a/tui/input.go b/tui/input.go index bb305cb..2ffd3e6 100644 --- a/tui/input.go +++ b/tui/input.go @@ -1,14 +1,67 @@ package tui -import "github.com/charmbracelet/lipgloss" +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/voocel/ainovel-cli/app" +) + +// renderInputBox 渲染底部栏:左快捷键 | 中输入框 | 右进度+目录。 +func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string { + // 左侧:快捷键提示 + keys := lipgloss.NewStyle().Foreground(colorDim).Render("Tab·^L·Esc") + + // 右侧:进度 + 输出目录 + right := buildRightInfo(snap, outputDir) + + // 中间:输入框,自适应宽度 + leftW := lipgloss.Width(keys) + rightW := lipgloss.Width(right) + sep := lipgloss.NewStyle().Foreground(colorDim).Render(" │ ") + sepW := lipgloss.Width(sep) + inputW := width - leftW - rightW - sepW*2 - 4 // 4 为 padding+border 余量 + if inputW < 20 { + inputW = 20 + } + + input := lipgloss.NewStyle().Width(inputW).Render(inputView) + + content := keys + sep + input + sep + right -// 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) + return style.Render(content) +} + +// buildRightInfo 构建右侧进度和目录信息。 +func buildRightInfo(snap app.UISnapshot, outputDir string) string { + var parts []string + + // 章节进度 + if snap.TotalChapters > 0 { + parts = append(parts, fmt.Sprintf("Ch %d/%d", snap.CompletedCount, snap.TotalChapters)) + } + + // 字数 + if snap.TotalWordCount > 0 { + parts = append(parts, formatNumber(snap.TotalWordCount)+"字") + } + + // 输出目录(缩短为相对路径的最后一段) + if outputDir != "" { + dir := filepath.Base(outputDir) + parts = append(parts, "./"+dir) + } + + if len(parts) == 0 { + return lipgloss.NewStyle().Foreground(colorDim).Render("READY") + } + return lipgloss.NewStyle().Foreground(colorDim).Render(strings.Join(parts, " · ")) } diff --git a/tui/model.go b/tui/model.go index 5340497..ecd494a 100644 --- a/tui/model.go +++ b/tui/model.go @@ -21,7 +21,7 @@ const ( modeDone // 创作完成 ) -// spinner 帧序列 +// 顶栏 spinner 帧序列 var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} // Model 是 TUI 的顶层状态。 @@ -29,11 +29,15 @@ type Model struct { runtime *app.Runtime snapshot app.UISnapshot events []app.UIEvent - viewport viewport.Model + viewport viewport.Model // 事件流 viewport + streamVP viewport.Model // 流式输出 viewport + streamBuf strings.Builder // 流式文本累积缓冲 textarea textarea.Model width int height int autoScroll bool + streamScroll bool // 流式面板自动跟随 + focusStream bool // true=焦点在流式面板, false=事件流 mode appMode err error spinnerIdx int @@ -44,7 +48,7 @@ func NewModel(rt *app.Runtime) Model { ta := textarea.New() ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说" ta.CharLimit = 500 - ta.MaxHeight = 3 + ta.MaxHeight = 1 ta.ShowLineNumbers = false ta.Focus() @@ -54,12 +58,17 @@ func NewModel(rt *app.Runtime) Model { vp := viewport.New(80, 20) vp.SetContent("") + svp := viewport.New(80, 10) + svp.SetContent("") + return Model{ - runtime: rt, - autoScroll: true, - mode: modeNew, - textarea: ta, - viewport: vp, + runtime: rt, + autoScroll: true, + streamScroll: true, + mode: modeNew, + textarea: ta, + viewport: vp, + streamVP: svp, } } @@ -68,6 +77,8 @@ func (m Model) Init() tea.Cmd { textarea.Blink, listenEvents(m.runtime), listenDone(m.runtime), + listenStream(m.runtime), + listenStreamClear(m.runtime), tickSnapshot(m.runtime), checkResume(m.runtime), tickSpinner(), @@ -81,7 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.textarea.SetWidth(m.width - 4) + m.textarea.SetWidth(m.inputWidth()) m.updateViewportSize() return m, nil @@ -96,6 +107,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.events = nil m.viewport.SetContent("") m.viewport.GotoTop() + m.streamBuf.Reset() + m.streamVP.SetContent("") + m.streamVP.GotoTop() + return m, nil + case tea.KeyTab: + m.focusStream = !m.focusStream return m, nil case tea.KeyEnter: text := strings.TrimSpace(m.textarea.Value()) @@ -113,31 +130,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyUp, tea.KeyPgUp: + if m.focusStream { + m.streamScroll = false + var cmd tea.Cmd + m.streamVP, cmd = m.streamVP.Update(msg) + return m, cmd + } m.autoScroll = false var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) return m, cmd case tea.KeyDown, tea.KeyPgDown: + if m.focusStream { + var cmd tea.Cmd + m.streamVP, cmd = m.streamVP.Update(msg) + if m.streamVP.AtBottom() { + m.streamScroll = true + } + return m, cmd + } 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() + if m.focusStream { + m.streamScroll = true + m.streamVP.GotoBottom() + } else { + 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 + if m.focusStream { + m.streamVP, cmd = m.streamVP.Update(msg) + if msg.Action == tea.MouseActionPress { + m.streamScroll = m.streamVP.AtBottom() + } + } else { + m.viewport, cmd = m.viewport.Update(msg) + if msg.Action == tea.MouseActionPress { + m.autoScroll = m.viewport.AtBottom() } } return m, cmd @@ -181,7 +220,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case spinnerTickMsg: m.spinnerIdx = (m.spinnerIdx + 1) % len(spinnerFrames) + if m.snapshot.IsRunning { + m.refreshEventViewport() + } return m, tickSpinner() + + case streamDeltaMsg: + m.streamBuf.WriteString(string(msg)) + m.streamVP.SetContent(m.streamBuf.String()) + if m.streamScroll { + m.streamVP.GotoBottom() + } + return m, listenStream(m.runtime) + + case streamClearMsg: + m.streamBuf.Reset() + m.streamVP.SetContent("") + m.streamVP.GotoTop() + m.streamScroll = true + return m, listenStreamClear(m.runtime) } // 更新 textarea 组件 @@ -196,6 +253,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) refreshEventViewport() { centerW := m.eventFlowWidth() content := renderEventContent(m.events, centerW) + if m.snapshot.IsRunning { + content += renderSparkle(m.spinnerIdx) + } m.viewport.SetContent(content) if m.autoScroll { m.viewport.GotoBottom() @@ -206,8 +266,39 @@ func (m *Model) refreshEventViewport() { func (m *Model) updateViewportSize() { centerW := m.eventFlowWidth() bodyH := m.bodyHeight() + eventH, streamH := m.splitHeights(bodyH) m.viewport.Width = centerW - 2 - m.viewport.Height = bodyH + m.viewport.Height = eventH + m.streamVP.Width = centerW - 2 + m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 +} + +// splitHeights 计算事件流和流式输出的高度分配。 +func (m *Model) splitHeights(bodyH int) (eventH, streamH int) { + eventH = bodyH * 40 / 100 + if eventH < 3 { + eventH = 3 + } + streamH = bodyH - eventH - 1 // -1 为分隔线 + if streamH < 3 { + streamH = 3 + } + return +} + +func (m *Model) inputWidth() int { + if m.width == 0 { + return 60 + } + // 与 renderInputBox 中 inputW 计算一致 + keysW := 10 // "Tab·^L·Esc" + rightW := 30 // 进度+目录预估 + sepW := 3 * 2 + w := m.width - keysW - rightW - sepW - 4 + if w < 20 { + w = 20 + } + return w } func (m *Model) eventFlowWidth() int { @@ -224,7 +315,7 @@ func (m *Model) bodyHeight() int { return 20 } topH := 1 - inputH := 3 + inputH := 2 // 单行输入 + top border bodyH := m.height - topH - inputH if bodyH < 3 { bodyH = 3 @@ -246,11 +337,11 @@ func (m Model) View() string { spinnerFrame := "" if m.snapshot.IsRunning { - spinnerFrame = spinnerFrames[m.spinnerIdx] + spinnerFrame = spinnerFrames[m.spinnerIdx%len(spinnerFrames)] } topBar := renderTopBar(m.snapshot, m.width, spinnerFrame) - inputBox := renderInputBox(m.textarea.View(), m.width) + inputBox := renderInputBox(m.textarea.View(), m.snapshot, m.runtime.Dir(), m.width) topH := lipgloss.Height(topBar) inputH := lipgloss.Height(inputBox) @@ -270,14 +361,22 @@ func (m Model) View() string { leftW := m.width * 25 / 100 rightW := m.width * 30 / 100 centerW := m.width - leftW - rightW + eventH, streamH := m.splitHeights(bodyH) - if m.viewport.Width != centerW-2 || m.viewport.Height != bodyH { + if m.viewport.Width != centerW-2 || m.viewport.Height != eventH { m.viewport.Width = centerW - 2 - m.viewport.Height = bodyH + m.viewport.Height = eventH + } + if m.streamVP.Width != centerW-2 || m.streamVP.Height != streamH-1 { + m.streamVP.Width = centerW - 2 + m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 } + eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH) + streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream) + center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel) + 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) } diff --git a/tui/panels.go b/tui/panels.go index c34dcf4..e4e4740 100644 --- a/tui/panels.go +++ b/tui/panels.go @@ -9,15 +9,33 @@ import ( "github.com/voocel/ainovel-cli/app" ) -// renderTopBar 渲染顶部状态栏。 +// 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) + // 第一行:小说名居中 + novelName := snap.NovelName + if novelName == "" { + novelName = "Novel Agent" } - left += " " + lipgloss.NewStyle().Foreground(colorDim).Render(snap.ModelName) + line1 := lipgloss.NewStyle(). + Width(width - 2). + AlignHorizontal(lipgloss.Center). + Foreground(colorText). + Bold(true). + Render("✦ " + novelName + " ✦") - // 状态胶囊 + // 第二行左侧:模型 + 风格 + var infoParts []string + if snap.ModelName != "" { + infoParts = append(infoParts, snap.ModelName) + } + if snap.Style != "" && snap.Style != "default" { + infoParts = append(infoParts, snap.Style) + } + left := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Join(infoParts, " · ")) + + // 第二行右侧:状态胶囊 label := snap.StatusLabel if label == "" { label = "READY" @@ -28,18 +46,21 @@ func renderTopBar(snap app.UISnapshot, width int, spinnerFrame string) string { } 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 } + line2 := left + strings.Repeat(" ", gap) + capsule - return topBarStyle.Width(width).Render(left + strings.Repeat(" ", gap) + capsule) + content := line1 + "\n" + line2 + return topBarStyle.Width(width). + Border(baseBorder, false, false, true, false). + BorderForeground(colorDim). + Render(content) } // renderStatePanel 渲染左侧状态面板。 @@ -89,6 +110,40 @@ func renderStatePanel(snap app.UISnapshot, width, height int) string { return style.Render(b.String()) } +// 星光动画帧 +var sparklePatterns = []string{ + " ✦ · ✧ · ", + " · ✧ ✦ · ", + " · ✦ · ✧ ", + " ✧ · ✧ ✦ ·", + " ✦ · ✧ · ", + " · ✧ ✦ · ", + " ✧ · · ✦ ✧ ", + " ✦ · ✧ ✦ · ", +} + +// renderSparkle 渲染事件流底部的星光加载动画。 +func renderSparkle(frame int) string { + idx := frame % len(sparklePatterns) + // 亮星用琥珀色,暗星用灰色 + line := sparklePatterns[idx] + var b strings.Builder + for _, ch := range line { + switch ch { + case '✦': + b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Render("✦")) + case '✧': + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#887730")).Render("✧")) + case '·': + b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render("·")) + default: + b.WriteRune(ch) + } + } + label := lipgloss.NewStyle().Foreground(lipgloss.Color("#887730")).Render(" AI 生成中…") + return "\n" + b.String() + "\n" + label +} + // renderEventContent 将事件列表渲染为纯文本(供 viewport 使用)。 func renderEventContent(events []app.UIEvent, width int) string { var b strings.Builder @@ -124,10 +179,83 @@ func renderEventFlowViewport(vp viewport.Model, width, height int) string { return style.Render(vp.View()) } +// renderStreamPanel 渲染流式输出面板(中间列下半部分)。 +func renderStreamPanel(vp viewport.Model, width, height int, focused bool) string { + // 分隔标题栏 + titleColor := colorDim + if focused { + titleColor = colorAccent + } + title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 生成内容") + lineW := width - lipgloss.Width(title) - 4 + if lineW < 0 { + lineW = 0 + } + separator := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW)) + header := " " + title + " " + separator + + // viewport 内容(height 包含 header 行,viewport 实际高度需减 1) + vpH := height - 1 + if vpH < 1 { + vpH = 1 + } + vpStyle := lipgloss.NewStyle(). + Width(width). + Height(vpH). + Padding(0, 1). + Foreground(colorText) + + return header + "\n" + vpStyle.Render(vp.View()) +} + // renderDetailPanel 渲染右侧详情面板。 +// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。 func renderDetailPanel(snap app.UISnapshot, width, height int) string { + contentW := width - 4 // 边框 + padding var b strings.Builder + // 大纲 + if len(snap.Outline) > 0 { + b.WriteString(panelTitleStyle.Render("大纲")) + b.WriteString("\n") + for _, e := range snap.Outline { + ch := fmt.Sprintf("%2d", e.Chapter) + // 已完成的章节用绿色标记 + marker := lipgloss.NewStyle().Foreground(colorDim).Render("○") + if snap.CompletedCount >= e.Chapter { + marker = lipgloss.NewStyle().Foreground(colorSuccess).Render("●") + } else if snap.InProgressChapter == e.Chapter { + marker = lipgloss.NewStyle().Foreground(colorAccent).Render("◐") + } + title := truncate(e.Title, contentW-6) + line := marker + lipgloss.NewStyle().Foreground(colorDim).Render(ch) + " " + + cardContentStyle.Render(title) + b.WriteString(line) + b.WriteString("\n") + } + b.WriteString("\n") + } + + // 角色 + if len(snap.Characters) > 0 { + b.WriteString(panelTitleStyle.Render("角色")) + b.WriteString("\n") + for _, c := range snap.Characters { + b.WriteString(cardContentStyle.Render("· " + truncate(c, contentW-2))) + b.WriteString("\n") + } + b.WriteString("\n") + } + + // 前提 + if snap.Premise != "" { + b.WriteString(panelTitleStyle.Render("前提")) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(truncate(snap.Premise, contentW*3))) + b.WriteString("\n\n") + } + + // 运行时信息 if snap.LastCommitSummary != "" { b.WriteString(cardTitleStyle.Render("─ 最近提交 ─")) b.WriteString("\n") @@ -142,18 +270,11 @@ func renderDetailPanel(snap app.UISnapshot, width, height int) string { 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(cardContentStyle.Render(truncate(s, contentW))) b.WriteString("\n") } }