559 lines
16 KiB
Go
559 lines
16 KiB
Go
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 {
|
||
// 分隔标题栏(始终醒目)
|
||
title := lipgloss.NewStyle().Foreground(colorAccent).Bold(focused).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 将流式输出按轮次渲染为语义分块。
|
||
// Agent 调度块(以 ▸ 开头)用 accent 标题 + dim 指令;正文块用标准文本色。
|
||
func renderStreamContent(rounds []string, width int) string {
|
||
if width < 24 {
|
||
width = 24
|
||
}
|
||
|
||
var blocks []string
|
||
for _, round := range rounds {
|
||
text := strings.TrimSpace(round)
|
||
if text == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(text, "▸") {
|
||
blocks = append(blocks, renderAgentBlock(text, width))
|
||
} else {
|
||
blocks = append(blocks, renderChapterBlock(text, width))
|
||
}
|
||
}
|
||
return strings.Join(blocks, "\n\n")
|
||
}
|
||
|
||
// renderAgentBlock 渲染 Agent 调度块:标题 + 分隔线 + 任务指令。
|
||
func renderAgentBlock(text string, width int) string {
|
||
headerLine, body, _ := strings.Cut(text, "\n")
|
||
|
||
// 标题行 + 分隔线
|
||
titleW := lipgloss.Width(headerLine)
|
||
lineW := max(0, width-titleW-1)
|
||
header := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(headerLine) +
|
||
" " + lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW))
|
||
|
||
var b strings.Builder
|
||
b.WriteString(header)
|
||
|
||
// 任务指令:dim 色,缩进 2 格
|
||
body = strings.TrimSpace(body)
|
||
if body != "" {
|
||
taskStyle := lipgloss.NewStyle().Foreground(colorMuted)
|
||
lines := wrapStreamText(body, max(16, width-6))
|
||
b.WriteString("\n")
|
||
for i, line := range lines {
|
||
if i > 0 {
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(taskStyle.Render(" " + line))
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// renderChapterBlock 渲染正文块,自动区分思考内容和章节正文。
|
||
// 思考内容(ThinkingSep 标记的段落)用淡色斜体,正文用标准文本色。
|
||
func renderChapterBlock(text string, width int) string {
|
||
contentStyle := lipgloss.NewStyle().Foreground(colorText)
|
||
thinkStyle := lipgloss.NewStyle().Foreground(colorDim).Italic(true)
|
||
wrapW := max(16, width-4)
|
||
|
||
// 按 ThinkingSep 分割:奇数段是思考,偶数段是正文
|
||
// 格式:[正文] \x02 [思考] [正文] \x02 [思考] ...
|
||
parts := strings.Split(text, app.ThinkingSep)
|
||
|
||
var b strings.Builder
|
||
for i, part := range parts {
|
||
part = strings.TrimRight(part, " ")
|
||
if part == "" {
|
||
continue
|
||
}
|
||
isThinking := i > 0 && i%2 != 0 // ThinkingSep 之后的奇数段是思考
|
||
// 如果整段都是思考标记开头(第一个 part 之前无正文),调整判断
|
||
if i == 0 && part == "" {
|
||
continue
|
||
}
|
||
|
||
style := contentStyle
|
||
if isThinking {
|
||
style = thinkStyle
|
||
}
|
||
|
||
lines := wrapStreamText(part, wrapW)
|
||
for j, line := range lines {
|
||
if b.Len() > 0 && j == 0 {
|
||
b.WriteString("\n")
|
||
} else if j > 0 {
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(style.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)
|
||
}
|