diff --git a/.gitignore b/.gitignore index ffccb5d..171ad8a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ release-notes.md docs/ refer/ +output diff --git a/domain/runtime.go b/domain/runtime.go index 48c99ab..5dba9fe 100644 --- a/domain/runtime.go +++ b/domain/runtime.go @@ -34,8 +34,10 @@ type Progress struct { InProgressChapter int `json:"in_progress_chapter,omitempty"` // 正在写作的章节(场景级恢复) CompletedScenes []int `json:"completed_scenes,omitempty"` // 当前章节已完成的场景编号 Flow FlowState `json:"flow,omitempty"` // 当前流程 - PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列 - RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 + PendingRewrites []int `json:"pending_rewrites,omitempty"` // 待重写章节队列 + RewriteReason string `json:"rewrite_reason,omitempty"` // 重写原因 + StrandHistory []string `json:"strand_history,omitempty"` // 按章节顺序记录 dominant_strand + HookHistory []string `json:"hook_history,omitempty"` // 按章节顺序记录 hook_type } // IsResumable 判断是否可以从断点恢复。 diff --git a/domain/story.go b/domain/story.go index e592c22..f5cb869 100644 --- a/domain/story.go +++ b/domain/story.go @@ -22,6 +22,7 @@ type Character struct { Description string `json:"description"` Arc string `json:"arc"` Traits []string `json:"traits"` + Tier string `json:"tier,omitempty"` // core / important / secondary / decorative(默认 important) } // WorldRule 世界观规则条目。 diff --git a/domain/writing.go b/domain/writing.go index 5977808..40a0d20 100644 --- a/domain/writing.go +++ b/domain/writing.go @@ -45,4 +45,6 @@ type CommitResult struct { NextChapter int `json:"next_chapter"` ReviewRequired bool `json:"review_required"` ReviewReason string `json:"review_reason,omitempty"` + HookType string `json:"hook_type,omitempty"` // 钩子类型:crisis/mystery/desire/emotion/choice + DominantStrand string `json:"dominant_strand,omitempty"` // 本章主导线:quest/fire/constellation } diff --git a/main.go b/main.go index 912c96b..eed5146 100644 --- a/main.go +++ b/main.go @@ -47,8 +47,8 @@ func main() { func buildConfig(style string) app.Config { provider := envOr("LLM_PROVIDER", "openai") - apiKey := os.Getenv("OPENAI_API_KEY") - baseURL := os.Getenv("OPENAI_BASE_URL") + apiKey := os.Getenv("Z_OPENAI_API_KEY") + baseURL := os.Getenv("Z_OPENAI_BASE_URL") if provider == "anthropic" { apiKey = envOr("ANTHROPIC_API_KEY", apiKey) baseURL = envOr("ANTHROPIC_BASE_URL", baseURL) diff --git a/prompts/editor.md b/prompts/editor.md index bf9e2fb..0e0c07f 100644 --- a/prompts/editor.md +++ b/prompts/editor.md @@ -10,37 +10,63 @@ ### 1. 获取上下文 调用 novel_context(chapter=最新章节号),获取全部状态数据。 -### 2. 审阅重点 +### 2. 六维结构化审阅 -逐项检查: +逐维度检查,每个维度必须给出结论(通过/存在问题)和具体问题列表: -**时间线一致性** -- 事件发生顺序是否合理 +#### 维度一:设定一致性 +- 事件发生顺序是否与时间线矛盾 - 时间跨度是否自洽 +- 世界规则边界是否被违反 +- 角色属性(能力、外貌、身份)是否前后矛盾 -**伏笔管理** -- 是否有长期未回收的伏笔(超过 5 章未推进视为遗忘风险) -- 新伏笔是否有回收计划 - -**人物关系** -- 关系变化是否自然 +#### 维度二:人设一致性 - 角色行为是否符合其性格设定和弧线 +- 对话风格是否与角色身份匹配 +- 角色动机是否合理连贯 -**结构问题** -- 节奏是否失衡(某几章过快或过慢) -- 主线是否推进 -- 是否存在重复或矛盾 +#### 维度三:节奏平衡 +- 是否连续多章同一类型(纯打斗、纯对话、纯描写) +- 主线是否持续推进,有无原地踏步 +- 情感节奏是否有张有弛 +- 如果有 strand_history 数据,检查 quest/fire/constellation 三线分布是否失衡 + +#### 维度四:叙事连贯 +- 场景之间过渡是否自然 +- 因果逻辑是否通顺 +- 信息传递是否一致(角色A不应知道只有角色B知道的事) + +#### 维度五:伏笔健康 +- 是否有超过 5 章未推进的伏笔(遗忘风险) +- 新伏笔是否有回收方向 +- 已回收伏笔的解决是否令人满意 + +#### 维度六:钩子质量 +- 章末钩子是否有足够吸引力 +- 如果有 hook_history 数据,检查是否连续使用同一类型的钩子 +- 钩子是否与主线推进方向一致 ### 3. 输出审阅 调用 save_review,给出: -- issues:发现的具体问题列表,每个问题包含类型、严重程度、描述和修改建议 +- issues:发现的具体问题列表,每个问题包含: + - type:问题维度(consistency/character/pacing/continuity/foreshadow/hook) + - severity:error 或 warning + - description:具体问题描述 + - suggestion:修改建议 - verdict:审阅结论 - - `accept`:质量合格,可以继续写 + - `accept`:所有维度通过或仅有 warning 级问题,可以继续写 - `polish`:存在细节问题,建议对特定章节做打磨 - - `rewrite`:存在结构性问题,建议重写特定章节 -- summary:审阅总结(200字以内) + - `rewrite`:存在 error 级结构性问题,建议重写特定章节 +- summary:审阅总结(200字以内),按维度概括 - affected_chapters:需要重写或打磨的章节号列表(verdict 为 polish/rewrite 时必填) +### 判定标准 + +- 任一维度出现 error 级问题 → verdict 至少为 polish +- 多个维度出现 error 级问题 → verdict 应为 rewrite +- 只有 warning 级问题 → verdict 为 accept +- 没有发现问题 → verdict 为 accept + ## 注意事项 - 不要自己修改正文 diff --git a/state/foreshadow.go b/state/foreshadow.go index 77559b4..4152948 100644 --- a/state/foreshadow.go +++ b/state/foreshadow.go @@ -41,6 +41,7 @@ func (s *Store) UpdateForeshadow(chapter int, updates []domain.ForeshadowUpdate) for _, u := range updates { switch u.Action { case "plant": + idx[u.ID] = len(entries) entries = append(entries, domain.ForeshadowEntry{ ID: u.ID, Description: u.Description, @@ -61,6 +62,21 @@ func (s *Store) UpdateForeshadow(chapter int, updates []domain.ForeshadowUpdate) return s.SaveForeshadowLedger(entries) } +// LoadActiveForeshadow 返回未回收的伏笔条目(status != "resolved")。 +func (s *Store) LoadActiveForeshadow() ([]domain.ForeshadowEntry, error) { + all, err := s.LoadForeshadowLedger() + if err != nil { + return nil, err + } + var active []domain.ForeshadowEntry + for _, e := range all { + if e.Status != "resolved" { + active = append(active, e) + } + } + return active, nil +} + func renderForeshadow(entries []domain.ForeshadowEntry) string { var b strings.Builder b.WriteString("# 伏笔账本\n\n") diff --git a/state/progress.go b/state/progress.go index 53775bf..4eed071 100644 --- a/state/progress.go +++ b/state/progress.go @@ -62,7 +62,8 @@ func (s *Store) UpdatePhase(phase domain.Phase) error { // MarkChapterComplete 标记章节完成,原子性更新进度。 // 支持重写场景:如果章节已完成,先减去旧字数再加新字数。 -func (s *Store) MarkChapterComplete(chapter, wordCount int) error { +// hookType 和 dominantStrand 用于节奏追踪,可为空。 +func (s *Store) MarkChapterComplete(chapter, wordCount int, hookType, dominantStrand string) error { p, err := s.LoadProgress() if err != nil { return err @@ -89,6 +90,29 @@ func (s *Store) MarkChapterComplete(chapter, wordCount int) error { p.InProgressChapter = 0 p.CompletedScenes = nil p.Phase = domain.PhaseWriting + + // 节奏追踪:按章节顺序填充 history(确保索引对齐) + if dominantStrand != "" { + for len(p.StrandHistory) < chapter-1 { + p.StrandHistory = append(p.StrandHistory, "") + } + if len(p.StrandHistory) < chapter { + p.StrandHistory = append(p.StrandHistory, dominantStrand) + } else { + p.StrandHistory[chapter-1] = dominantStrand + } + } + if hookType != "" { + for len(p.HookHistory) < chapter-1 { + p.HookHistory = append(p.HookHistory, "") + } + if len(p.HookHistory) < chapter { + p.HookHistory = append(p.HookHistory, hookType) + } else { + p.HookHistory[chapter-1] = hookType + } + } + return s.SaveProgress(p) } diff --git a/state/timeline.go b/state/timeline.go index f46bd12..f1e0c22 100644 --- a/state/timeline.go +++ b/state/timeline.go @@ -37,6 +37,22 @@ func (s *Store) AppendTimelineEvents(newEvents []domain.TimelineEvent) error { return s.SaveTimeline(append(existing, newEvents...)) } +// LoadRecentTimeline 返回最近 window 章内的时间线事件(chapter >= current-window)。 +func (s *Store) LoadRecentTimeline(current, window int) ([]domain.TimelineEvent, error) { + all, err := s.LoadTimeline() + if err != nil { + return nil, err + } + minCh := max(current-window, 1) + var filtered []domain.TimelineEvent + for _, e := range all { + if e.Chapter >= minCh { + filtered = append(filtered, e) + } + } + return filtered, nil +} + func renderTimeline(events []domain.TimelineEvent) string { var b strings.Builder b.WriteString("# 时间线\n\n") diff --git a/tools/commit_chapter.go b/tools/commit_chapter.go index 58ed0e4..6c7e631 100644 --- a/tools/commit_chapter.go +++ b/tools/commit_chapter.go @@ -50,6 +50,8 @@ func (t *CommitChapterTool) Schema() map[string]any { schema.Property("timeline_events", schema.Array("本章时间线事件", timelineSchema)), schema.Property("foreshadow_updates", schema.Array("伏笔操作", foreshadowSchema)), schema.Property("relationship_changes", schema.Array("关系变化", relationshipSchema)), + schema.Property("hook_type", schema.Enum("章末钩子类型", "crisis", "mystery", "desire", "emotion", "choice")), + schema.Property("dominant_strand", schema.Enum("本章主导叙事线", "quest", "fire", "constellation")), ) } @@ -62,6 +64,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js TimelineEvents []domain.TimelineEvent `json:"timeline_events"` ForeshadowUpdates []domain.ForeshadowUpdate `json:"foreshadow_updates"` RelationshipChanges []domain.RelationshipEntry `json:"relationship_changes"` + HookType string `json:"hook_type"` + DominantStrand string `json:"dominant_strand"` } if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("invalid args: %w", err) @@ -122,7 +126,7 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js } // 5. 更新进度 - if err := t.store.MarkChapterComplete(a.Chapter, wordCount); err != nil { + if err := t.store.MarkChapterComplete(a.Chapter, wordCount, a.HookType, a.DominantStrand); err != nil { return nil, fmt.Errorf("mark chapter complete: %w", err) } @@ -152,6 +156,8 @@ func (t *CommitChapterTool) Execute(_ context.Context, args json.RawMessage) (js NextChapter: a.Chapter + 1, ReviewRequired: reviewRequired, ReviewReason: reviewReason, + HookType: a.HookType, + DominantStrand: a.DominantStrand, } // 9. 写入信号文件供宿主程序读取(优先于清理操作,确保信号不丢失) diff --git a/tools/novel_context.go b/tools/novel_context.go index f97ed53..3bb6ee2 100644 --- a/tools/novel_context.go +++ b/tools/novel_context.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/voocel/agentcore/schema" + "github.com/voocel/ainovel-cli/domain" "github.com/voocel/ainovel-cli/state" ) @@ -67,15 +68,14 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw if outline, err := t.store.LoadOutline(); err == nil && outline != nil { result["outline"] = outline } - if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { - result["characters"] = chars - } - if rules, err := t.store.LoadWorldRules(); err == nil && len(rules) > 0 { result["world_rules"] = rules } if a.Chapter > 0 { + // 角色按 Tier 过滤:core/important 始终返回,secondary/decorative 按出场匹配 + t.loadFilteredCharacters(result, a.Chapter) + // Writer/Editor 模式:加载章节相关上下文 if entry, err := t.store.GetChapterOutline(a.Chapter); err == nil { result["current_chapter_outline"] = entry @@ -83,53 +83,104 @@ func (t *ContextTool) Execute(_ context.Context, args json.RawMessage) (json.Raw if summaries, err := t.store.LoadRecentSummaries(a.Chapter, 2); err == nil && len(summaries) > 0 { result["recent_summaries"] = summaries } - // V1: 加载状态数据 - if timeline, err := t.store.LoadTimeline(); err == nil && len(timeline) > 0 { + + // V3: 状态数据分级加载 + // timeline:只取最近 5 章的事件(避免后期全量膨胀) + if timeline, err := t.store.LoadRecentTimeline(a.Chapter, 5); err == nil && len(timeline) > 0 { result["timeline"] = timeline } - if foreshadow, err := t.store.LoadForeshadowLedger(); err == nil && len(foreshadow) > 0 { + // foreshadow:只取未回收条目(已回收的对后续写作无意义) + if foreshadow, err := t.store.LoadActiveForeshadow(); err == nil && len(foreshadow) > 0 { result["foreshadow_ledger"] = foreshadow } + // relationships:保持全量(pair-key 去重,数据量天然可控) if relationships, err := t.store.LoadRelationships(); err == nil && len(relationships) > 0 { result["relationship_state"] = relationships } - // V2: 加载场景级恢复状态 + + // V2: 加载场景级恢复状态 + 节奏追踪 if progress, err := t.store.LoadProgress(); err == nil && progress != nil { checkpoint := map[string]any{ "in_progress_chapter": progress.InProgressChapter, "completed_scenes": progress.CompletedScenes, } + if len(progress.StrandHistory) > 0 { + checkpoint["strand_history"] = progress.StrandHistory + } + if len(progress.HookHistory) > 0 { + checkpoint["hook_history"] = progress.HookHistory + } result["checkpoint"] = checkpoint } // V2: 加载已有的章节规划(支持场景恢复跳过已完成场景) if plan, err := t.store.LoadChapterPlan(a.Chapter); err == nil && plan != nil { result["chapter_plan"] = plan } - // 写作参考资料 - result["references"] = t.writerReferences() + + // V3: 写作参考资料分阶段加载 + result["references"] = t.writerReferences(a.Chapter) } else { - // Architect 模式:加载模板 + // Architect 模式:全量角色 + 模板 + if chars, err := t.store.LoadCharacters(); err == nil && chars != nil { + result["characters"] = chars + } result["references"] = t.architectReferences() } return json.Marshal(result) } -func (t *ContextTool) writerReferences() map[string]string { +// loadFilteredCharacters 按 Tier 和场景出场过滤角色。 +// core/important 始终返回;secondary/decorative 只在当前章节大纲提及时返回。 +func (t *ContextTool) loadFilteredCharacters(result map[string]any, chapter int) { + chars, err := t.store.LoadCharacters() + if err != nil || len(chars) == 0 { + return + } + + // 获取当前章节大纲的场景描述,用于匹配次要角色 + entry, err := t.store.GetChapterOutline(chapter) + if err != nil { + result["characters"] = chars + return + } + sceneText := strings.Join(entry.Scenes, " ") + " " + entry.CoreEvent + " " + entry.Title + + var filtered []domain.Character + for _, c := range chars { + switch c.Tier { + case "secondary", "decorative": + if strings.Contains(sceneText, c.Name) { + filtered = append(filtered, c) + } + default: // core, important, 或未设置 + filtered = append(filtered, c) + } + } + result["characters"] = filtered +} + +// writerReferences 返回写作参考资料。章节 1 返回全量,后续章节裁剪掉不再需要的模板。 +func (t *ContextTool) writerReferences(chapter int) map[string]string { refs := map[string]string{} add := func(k, v string) { if v != "" { refs[k] = v } } + // 始终加载的核心参考 add("chapter_guide", t.refs.ChapterGuide) add("hook_techniques", t.refs.HookTechniques) add("quality_checklist", t.refs.QualityChecklist) - add("chapter_template", t.refs.ChapterTemplate) add("consistency", t.refs.Consistency) - add("content_expansion", t.refs.ContentExpansion) add("dialogue_writing", t.refs.DialogueWriting) add("style_reference", t.refs.StyleReference) + + // 仅首章加载的补充参考(后续章节不再需要) + if chapter <= 1 { + add("chapter_template", t.refs.ChapterTemplate) + add("content_expansion", t.refs.ContentExpansion) + } return refs } diff --git a/tui/input.go b/tui/input.go index 2ffd3e6..b949a3f 100644 --- a/tui/input.go +++ b/tui/input.go @@ -9,55 +9,60 @@ import ( "github.com/voocel/ainovel-cli/app" ) -// renderInputBox 渲染底部栏:左快捷键 | 中输入框 | 右进度+目录。 +// renderInputBox 渲染底部栏(两行布局)。 +// 第一行:❯ + 输入框 +// 第二行:左快捷键提示,右进度信息 func renderInputBox(inputView string, snap app.UISnapshot, outputDir string, width int) string { - // 左侧:快捷键提示 - keys := lipgloss.NewStyle().Foreground(colorDim).Render("Tab·^L·Esc") + innerW := width - 4 // border + padding - // 右侧:进度 + 输出目录 - right := buildRightInfo(snap, outputDir) + // 第一行:提示符 + 输入框 + prompt := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("❯ ") + line1 := prompt + inputView - // 中间:输入框,自适应宽度 - 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 + // 第二行:左快捷键,右进度 + hints := lipgloss.NewStyle().Foreground(colorDim).Render("Tab 切换 · ^L 清屏 · Esc 重置 · Enter 发送") + info := buildRightInfo(snap, outputDir) + + hintsW := lipgloss.Width(hints) + infoW := lipgloss.Width(info) + gap := innerW - hintsW - infoW + if gap < 1 { + gap = 1 } + line2 := hints + strings.Repeat(" ", gap) + info - input := lipgloss.NewStyle().Width(inputW).Render(inputView) - - content := keys + sep + input + sep + right - - style := lipgloss.NewStyle(). + // 输入区(上横线 + 输入行) + inputStyle := lipgloss.NewStyle(). Width(width). - Border(baseBorder, true, false, false, false). + Border(baseBorder, true, false, true, false). BorderForeground(colorDim). Padding(0, 1) + inputBlock := inputStyle.Render(line1) - return style.Render(content) + // 提示行(无边框,紧贴下横线下方) + hintStyle := lipgloss.NewStyle(). + Width(width). + Padding(0, 2) + hintBlock := hintStyle.Render(line2) + + return inputBlock + "\n" + hintBlock + "\n" } // buildRightInfo 构建右侧进度和目录信息。 func buildRightInfo(snap app.UISnapshot, outputDir string) string { var parts []string - // 章节进度 + if snap.ModelName != "" { + parts = append(parts, snap.ModelName) + } 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) + parts = append(parts, "./"+filepath.Base(outputDir)) } if len(parts) == 0 { diff --git a/tui/model.go b/tui/model.go index ecd494a..9014b9f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -31,7 +31,7 @@ type Model struct { events []app.UIEvent viewport viewport.Model // 事件流 viewport streamVP viewport.Model // 流式输出 viewport - streamBuf strings.Builder // 流式文本累积缓冲 + streamBuf *strings.Builder // 流式文本累积缓冲 textarea textarea.Model width int height int @@ -41,6 +41,7 @@ type Model struct { mode appMode err error spinnerIdx int + streamRound int // 流式输出轮次计数 } // NewModel 创建 TUI Model。 @@ -48,6 +49,7 @@ func NewModel(rt *app.Runtime) Model { ta := textarea.New() ta.Placeholder = "输入小说需求,例如:写一部12章都市悬疑小说" ta.CharLimit = 500 + ta.SetHeight(1) ta.MaxHeight = 1 ta.ShowLineNumbers = false ta.Focus() @@ -69,6 +71,7 @@ func NewModel(rt *app.Runtime) Model { textarea: ta, viewport: vp, streamVP: svp, + streamBuf: &strings.Builder{}, } } @@ -110,6 +113,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamBuf.Reset() m.streamVP.SetContent("") m.streamVP.GotoTop() + m.streamRound = 0 return m, nil case tea.KeyTab: m.focusStream = !m.focusStream @@ -234,10 +238,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, listenStream(m.runtime) case streamClearMsg: - m.streamBuf.Reset() - m.streamVP.SetContent("") - m.streamVP.GotoTop() - m.streamScroll = true + // 新一轮输出:保留历史内容,用分隔线标记新段落 + m.streamRound++ + if m.streamBuf.Len() > 0 { + m.streamBuf.WriteString("\n") + m.streamBuf.WriteString(renderStreamSeparator(m.streamRound, m.streamVP.Width)) + m.streamBuf.WriteString("\n") + } + m.streamVP.SetContent(m.streamBuf.String()) + if m.streamScroll { + m.streamVP.GotoBottom() + } return m, listenStreamClear(m.runtime) } @@ -290,15 +301,7 @@ 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 + return m.width - 6 // border + padding + 提示符 "❯ " } func (m *Model) eventFlowWidth() int { @@ -315,7 +318,7 @@ func (m *Model) bodyHeight() int { return 20 } topH := 1 - inputH := 2 // 单行输入 + top border + inputH := 6 // top border + 输入行 + bottom border + 空行 + 提示行 + \n bodyH := m.height - topH - inputH if bodyH < 3 { bodyH = 3 diff --git a/tui/panels.go b/tui/panels.go index e4e4740..439b265 100644 --- a/tui/panels.go +++ b/tui/panels.go @@ -208,6 +208,19 @@ func renderStreamPanel(vp viewport.Model, width, height int, focused bool) strin return header + "\n" + vpStyle.Render(vp.View()) } +// renderStreamSeparator 渲染流式面板中的轮次分隔线。 +func renderStreamSeparator(round, width int) string { + label := fmt.Sprintf(" #%d ", round) + lineW := (width - lipgloss.Width(label)) / 2 + if lineW < 1 { + lineW = 1 + } + line := strings.Repeat("─", lineW) + dimLine := lipgloss.NewStyle().Foreground(colorDim).Render(line) + dimLabel := lipgloss.NewStyle().Foreground(colorDim).Render(label) + return dimLine + dimLabel + dimLine +} + // renderDetailPanel 渲染右侧详情面板。 // 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。 func renderDetailPanel(snap app.UISnapshot, width, height int) string {