Files
ainovel-clients/tui/panels.go

517 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package tui
import (
"encoding/json"
"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 {
// 第一行:小说名居中
novelName := snap.NovelName
if novelName == "" {
novelName = "Novel Agent"
}
line1 := lipgloss.NewStyle().
Width(width - 2).
AlignHorizontal(lipgloss.Center).
Foreground(colorText).
Bold(true).
Render("✦ " + novelName + " ✦")
// 第二行左侧:模型 + 风格
var infoParts []string
if snap.Provider != "" {
infoParts = append(infoParts, snap.Provider)
}
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"
}
color, ok := statusColors[label]
if !ok {
color = colorDim
}
capsule := statusCapsule.Foreground(lipgloss.Color("#1a1a2e")).Background(color).Render(label)
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
content := line1 + "\n" + line2
return topBarStyle.Width(width).
Border(baseBorder, false, false, true, false).
BorderForeground(colorDim).
Render(content)
}
// 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章", snap.InProgressChapter)))
}
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())
}
// 星光动画帧
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
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, 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
vpH := height - 1
if vpH < 1 {
vpH = 1
}
style := lipgloss.NewStyle().
Width(width).
Height(vpH).
Padding(0, 1)
return header + "\n" + 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())
}
// renderStreamContent 将流式输出按轮次渲染为分块内容,避免长段直接拼接导致错乱。
func renderStreamContent(rounds []string, width int) string {
if width < 24 {
width = 24
}
var blocks []string
displayIndex := 0
for i, round := range rounds {
text := strings.TrimSpace(round)
if text == "" {
continue
}
displayIndex++
blocks = append(blocks, renderStreamBlock(displayIndex, text, width, i == len(rounds)-1))
}
return strings.Join(blocks, "\n\n")
}
func renderStreamBlock(index int, text string, width int, active bool) string {
headerStyle := lipgloss.NewStyle().Foreground(colorDim)
bodyStyle := lipgloss.NewStyle().Foreground(colorText)
dividerColor := colorDim
if active {
headerStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
dividerColor = colorAccent
}
header := headerStyle.Render(fmt.Sprintf("◆ 第 %d 段", index))
divider := lipgloss.NewStyle().Foreground(dividerColor).Render(strings.Repeat("─", max(8, width)))
lines := wrapStreamText(text, max(16, width-4))
var b strings.Builder
b.WriteString(header)
b.WriteString("\n")
b.WriteString(divider)
b.WriteString("\n")
for i, line := range lines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(bodyStyle.Render(line))
}
return b.String()
}
func wrapStreamText(text string, width int) []string {
if width < 8 {
return []string{text}
}
var out []string
for _, raw := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
if strings.TrimSpace(raw) == "" {
out = append(out, "")
continue
}
if compact, ok := compactJSONLine(raw, width); ok {
out = append(out, compact)
continue
}
prefix, rest, nextPrefix := parseWrapPrefix(raw)
wrapped := wrapRunes(rest, max(4, width-lipgloss.Width(prefix)))
for i, line := range wrapped {
if i == 0 {
out = append(out, prefix+line)
continue
}
out = append(out, nextPrefix+line)
}
}
return out
}
func compactJSONLine(line string, width int) (string, bool) {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return "", false
}
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
return "", false
}
var value any
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
return "", false
}
compact, err := json.Marshal(value)
if err != nil {
return "", false
}
text := string(compact)
limit := max(24, width-2)
if lipgloss.Width(text) > limit {
text = truncate(text, limit-1)
}
return lipgloss.NewStyle().Foreground(colorDim).Render("JSON: ") +
lipgloss.NewStyle().Foreground(lipgloss.Color("#8fb7c9")).Render(text), true
}
func parseWrapPrefix(line string) (prefix, content, nextPrefix string) {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "), strings.HasPrefix(trimmed, "• "):
prefix = indent + trimmed[:2]
content = strings.TrimSpace(trimmed[2:])
nextPrefix = indent + " "
return prefix, content, nextPrefix
case orderedListPrefix(trimmed) != "":
marker := orderedListPrefix(trimmed)
prefix = indent + marker
content = strings.TrimSpace(strings.TrimPrefix(trimmed, marker))
nextPrefix = indent + strings.Repeat(" ", lipgloss.Width(marker))
return prefix, content, nextPrefix
case strings.HasPrefix(trimmed, "```"):
return indent, trimmed, indent
default:
return indent, trimmed, indent
}
}
func orderedListPrefix(line string) string {
end := strings.Index(line, ". ")
if end <= 0 {
return ""
}
for _, r := range line[:end] {
if r < '0' || r > '9' {
return ""
}
}
return line[:end+2]
}
func wrapRunes(text string, width int) []string {
if text == "" {
return []string{""}
}
if width < 2 {
return []string{text}
}
var lines []string
var current strings.Builder
currentWidth := 0
for _, r := range text {
rw := lipgloss.Width(string(r))
if currentWidth > 0 && currentWidth+rw > width {
lines = append(lines, strings.TrimRight(current.String(), " "))
current.Reset()
currentWidth = 0
if r == ' ' {
continue
}
}
current.WriteRune(r)
currentWidth += rw
}
if current.Len() > 0 {
lines = append(lines, strings.TrimRight(current.String(), " "))
}
if len(lines) == 0 {
return []string{""}
}
return lines
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// renderDetailContent 构建右侧详情面板内容。
// 优先展示基础设定(大纲、角色),然后是运行时信息(提交、审阅等)。
func renderDetailContent(snap app.UISnapshot, contentW int) string {
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")
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 len(snap.RecentSummaries) > 0 {
b.WriteString(cardTitleStyle.Render("─ 摘要 ─"))
b.WriteString("\n")
for _, s := range snap.RecentSummaries {
b.WriteString(cardContentStyle.Render(truncate(s, contentW)))
b.WriteString("\n")
}
}
return b.String()
}
// renderDetailPanel 渲染右侧可滚动详情面板。
func renderDetailPanel(vp viewport.Model, width, height int, focused bool) string {
borderColor := colorDim
if focused {
borderColor = colorAccent
}
style := lipgloss.NewStyle().
Width(width).
Height(height).
Border(baseBorder, false, false, false, true).
BorderForeground(borderColor).
Padding(0, 1)
return style.Render(vp.View())
}
// 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)
}